Amazon S3 / Cloud File Storage

~25 daq · NestJS · Fayllar va Xavfsizlik

Muammodan boshlaylik

Oldingi darsda fayllarni serverga saqladik. Produksiyada bu muammo:

  • Server qayta ishga tushsa — uploads/ papkasi o'chishi mumkin
  • Ikki server (load balancer) bo'lsa — fayl birinchisida, so'rov ikkinchisiga tushsa — topilmaydi
  • Server diskin kattalashtirish qimmat va qiyin
  • CDN orqali tez yetkazib berish yo'q

S3 — Amazon ning bulut fayl xizmati. Fayllar serverdan tashqarida, alohida tizimda saqlanadi. Cheksiz hajm, yuqori ishonchlilik, CDN bilan integratsiya.


Loyiha tuzilishi

src/
└── upload/
    ├── upload.module.ts
    ├── upload.controller.ts
    ├── upload.service.ts
    ├── multer.config.ts      ← lokal saqlash (dev uchun)
    └── s3.service.ts         ← S3 bilan ishlash (prod uchun)

S3 ni alohida service da ushlab, upload.service.ts unga murojaat qiladi. Agar S3 o'rniga boshqa xizmat (Cloudinary, Supabase Storage) ishlatilsa — faqat s3.service.ts almashtiriladi.


AWS sozlash

IAM foydalanuvchi va bucket yaratish

  1. aws.amazon.com → S3 → Create bucket
  2. Bucket nomi: myapp-uploads (global unikal bo'lishi kerak)
  3. Region: ap-southeast-1 (Singapore — O'zbekistonga yaqin)
  4. Block all public access — hozircha yoqiq qolsin (Signed URL ishlatamiz)

IAM foydalanuvchi:

  1. IAM → Users → Create user
  2. Policy: AmazonS3FullAccess (yoki faqat kerakli bucket uchun maxsus policy)
  3. Access Keys → Create → ACCESS_KEY_ID va SECRET_ACCESS_KEY ni saqlang
# .env
AWS_REGION=ap-southeast-1
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI...
AWS_S3_BUCKET=myapp-uploads

O'rnatish

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
npm install multer @types/multer

@aws-sdk/client-s3 — AWS SDK v3 (modular, yengil). @aws-sdk/s3-request-presigner — Signed URL yaratish uchun.


S3 Service

// src/upload/s3.service.ts
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
  S3Client,
  PutObjectCommand,
  DeleteObjectCommand,
  GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { v4 as uuid } from 'uuid';
import { extname } from 'path';

@Injectable()
export class S3Service {
  private s3: S3Client;
  private bucket: string;

  constructor(private config: ConfigService) {
    this.s3 = new S3Client({
      region: this.config.get('AWS_REGION'),
      credentials: {
        accessKeyId:     this.config.get('AWS_ACCESS_KEY_ID'),
        secretAccessKey: this.config.get('AWS_SECRET_ACCESS_KEY'),
      },
    });
    this.bucket = this.config.get('AWS_S3_BUCKET');
  }

  // Fayl yuklash
  async upload(
    file: Express.Multer.File,
    folder: string = 'general',
  ): Promise<{ key: string; url: string }> {
    const ext = extname(file.originalname).toLowerCase();
    const key = `${folder}/${uuid()}${ext}`; // ← S3 dagi "yo'l"

    try {
      await this.s3.send(
        new PutObjectCommand({
          Bucket:      this.bucket,
          Key:         key,
          Body:        file.buffer,       // ← fayl buffer (memoryStorage kerak)
          ContentType: file.mimetype,
          // Ommaviy bo'lmasin — signed URL orqali ko'rsatamiz
        }),
      );
    } catch (err) {
      throw new InternalServerErrorException('Fayl yuklashda xato: ' + err.message);
    }

    return {
      key,
      url: `https://${this.bucket}.s3.${this.config.get('AWS_REGION')}.amazonaws.com/${key}`,
    };
  }

  // Faylni o'chirish
  async delete(key: string): Promise<void> {
    try {
      await this.s3.send(
        new DeleteObjectCommand({ Bucket: this.bucket, Key: key }),
      );
    } catch (err) {
      // O'chirmasa ham davom — log qilib ketish
      console.error('S3 delete xato:', err.message);
    }
  }

  // Vaqtinchalik URL yaratish (private fayllar uchun)
  async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
    const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
    return getSignedUrl(this.s3, command, { expiresIn }); // ← 1 soat
  }
}

file.buffer nima uchun? S3 ga yuklashda fayl diskga yozilmaydi — buffer (xotira) dan to'g'ridan-to'g'ri S3 ga yuboriladi. Buning uchun Multer memoryStorage ishlatishi kerak.


Upload Service — S3 bilan

// src/upload/upload.service.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { S3Service } from './s3.service';
import { extname } from 'path';

const IMAGE_TYPES = ['.jpg', '.jpeg', '.png', '.webp'];
const DOC_TYPES   = ['.pdf', '.doc', '.docx'];

@Injectable()
export class UploadService {
  constructor(private s3Service: S3Service) {}

  async uploadAvatar(file: Express.Multer.File) {
    this.validateFile(file, IMAGE_TYPES, 2); // 2MB limit
    return this.s3Service.upload(file, 'avatars');
  }

  async uploadDocument(file: Express.Multer.File) {
    this.validateFile(file, DOC_TYPES, 10); // 10MB limit
    return this.s3Service.upload(file, 'documents');
  }

  async deleteFile(key: string) {
    return this.s3Service.delete(key);
  }

  async getSignedUrl(key: string) {
    return this.s3Service.getSignedUrl(key, 3600); // 1 soat
  }

  private validateFile(
    file: Express.Multer.File,
    allowedTypes: string[],
    maxMb: number,
  ) {
    if (!file) throw new BadRequestException('Fayl yuklanmadi');

    const ext = extname(file.originalname).toLowerCase();
    if (!allowedTypes.includes(ext)) {
      throw new BadRequestException(
        `Faqat ${allowedTypes.join(', ')} ruxsat`
      );
    }

    const maxBytes = maxMb * 1024 * 1024;
    if (file.size > maxBytes) {
      throw new BadRequestException(`Maksimal hajm: ${maxMb}MB`);
    }
  }
}

Multer — memoryStorage (S3 uchun)

// src/upload/multer.config.ts
import { memoryStorage } from 'multer';

// S3 uchun — diskka yozmasdan xotirada ushlab turadi
export const s3UploadConfig = {
  storage: memoryStorage(), // ← disk emas, xotira
  limits: { fileSize: 10 * 1024 * 1024 }, // 10MB (eng katta limit)
  // Kichikroq limit service da tekshiriladi
};

Controller

// src/upload/upload.controller.ts
import {
  Controller, Post, Delete, Get,
  Param, UseInterceptors, UploadedFile,
  UseGuards,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UploadService } from './upload.service';
import { s3UploadConfig } from './multer.config';

@Controller('upload')
@UseGuards(JwtAuthGuard)
export class UploadController {
  constructor(private uploadService: UploadService) {}

  // POST /upload/avatar
  @Post('avatar')
  @UseInterceptors(FileInterceptor('file', s3UploadConfig))
  uploadAvatar(@UploadedFile() file: Express.Multer.File) {
    return this.uploadService.uploadAvatar(file);
    // → { key: "avatars/uuid.jpg", url: "https://..." }
  }

  // POST /upload/document
  @Post('document')
  @UseInterceptors(FileInterceptor('file', s3UploadConfig))
  uploadDocument(@UploadedFile() file: Express.Multer.File) {
    return this.uploadService.uploadDocument(file);
  }

  // DELETE /upload/:key — fayl o'chirish
  @Delete(':key')
  deleteFile(@Param('key') key: string) {
    return this.uploadService.deleteFile(decodeURIComponent(key));
  }

  // GET /upload/signed/:key — vaqtinchalik URL
  @Get('signed/:key')
  getSignedUrl(@Param('key') key: string) {
    return this.uploadService.getSignedUrl(decodeURIComponent(key));
  }
}

Upload Module

// src/upload/upload.module.ts
import { Module } from '@nestjs/common';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
import { S3Service } from './s3.service';

@Module({
  controllers: [UploadController],
  providers: [UploadService, S3Service],
  exports: [UploadService, S3Service], // boshqa modullar uchun
})
export class UploadModule {}

Public vs Private fayllar

Public fayl (avatar, banner):
  → bucket da public access yoqiladi
  → to'g'ridan-to'g'ri URL ishlaydi
  → https://bucket.s3.region.amazonaws.com/avatars/uuid.jpg

Private fayl (hujjat, shartnoma):
  → bucket private qoladi
  → Signed URL — vaqtinchalik, tokenli URL
  → https://bucket.s3.region.amazonaws.com/docs/uuid.pdf?X-Amz-Signature=...&X-Amz-Expires=3600
  → 1 soatdan keyin ishlamaydi
// Public avatar — to'g'ridan-to'g'ri URL
const avatarUrl = result.url; // https://...s3.../avatars/uuid.jpg

// Private hujjat — har ko'rishda signed URL
const signedUrl = await this.s3Service.getSignedUrl(doc.key, 300); // 5 daqiqa

User da S3 key saqlash

// user.schema.ts
@Schema({ timestamps: true })
export class User {
  @Prop()
  name: string;

  // S3 key — to'liq URL emas, faqat kalit
  // Chunki bucket nomi o'zgarishi mumkin
  @Prop({ default: null })
  avatarKey: string | null;   // "avatars/uuid.jpg"

  @Prop({ default: null })
  avatarUrl: string | null;   // "https://...amazonaws.com/avatars/uuid.jpg"
}

Nima uchun key alohida? Bucket nomi yoki region o'zgarsa — faqat URL qayta hisoblanadi, key bazada o'zgarmaydi.


Tez-tez uchraydigan xatolar

memoryStorage bilan xotira to'lib ketishi

// ❌ Katta fayllar xotirani to'ldiradi
storage: memoryStorage(),
limits: { fileSize: 100 * 1024 * 1024 } // 100MB — xavfli

// ✅ S3 uchun ham kichik limit
limits: { fileSize: 10 * 1024 * 1024 } // 10MB maksimum
// Katta fayllar uchun → Multipart Upload (alohida mavzu)

AWS credentials .env da emas, kodda

// ❌ Hech qachon
const s3 = new S3Client({
  credentials: {
    accessKeyId: 'AKIA...', // ← kodda qolsa git da ko'rinadi
    secretAccessKey: 'wJalr...',
  },
});

// ✅ ConfigService orqali
accessKeyId: this.config.get('AWS_ACCESS_KEY_ID'),

Eski faylni o'chirmaslik

// ❌ Avatar yangilananda eski fayl S3 da qolaveradi — pul ketadi
await this.s3Service.upload(newFile, 'avatars');

// ✅ Avval eski faylni o'chirish
if (user.avatarKey) {
  await this.s3Service.delete(user.avatarKey);
}
const result = await this.s3Service.upload(newFile, 'avatars');

Xulosa

S3 — serverdan mustaqil, cheksiz hajmli fayl saqlash. Lokal disk o'rniga fayllar AWS da saqlanadi — server qayta ishga tushsa ham fayllar saqlanadi.

Eslab qoling:

  • memoryStorage — S3 uchun (disk emas)
  • key — S3 dagi fayl yo'li (avatars/uuid.jpg)
  • url — to'liq manzil (https://bucket.s3.region.amazonaws.com/key)
  • Public fayl → to'g'ridan-to'g'ri URL
  • Private fayl → getSignedUrl (vaqtinchalik tokenli URL)
  • Bazada key saqlash — URL emas (bucket o'zgarishi mumkin)
  • Avatar yangilananda eski faylni S3 dan o'chirish

Keyingi dars

32-dars: Rate Limiting va Throttling