깜놀하는 해므찌로

Ionic Anular nestjs video Upload example / 앵귤러 nestjs 동영상 파일 업로드 예시 feat.AWS S3 본문

IT

Ionic Anular nestjs video Upload example / 앵귤러 nestjs 동영상 파일 업로드 예시 feat.AWS S3

agnusdei1207 2023. 8. 17. 13:11
반응형
SMALL
<div class="flex flex-col gap-2">
  <!-- Start: Label -->
  <div class="relative flex flex-col gap-2 mb-2 w-max" *ngIf="label">
    <!-- Display the label text -->
    <span class="text-xs">{{ label }}</span>
    <!-- Display an asterisk (*) if the field is required or has a minimum length -->
    <p
      class="text-primary absolute -top-[8px] left-[calc(100%+2px)]"
      *ngIf="required || minlength"
    >
      *
    </p>
  </div>
  <!-- End: Label -->

  <div class="flex flex-col gap-6">
    <div class="flex flex-col gap-2" *ngIf="items.length !== this.maxlength">
      <!-- File upload area -->
      <div
        class="flex flex-col items-center justify-center w-full gap-4 p-4 transition-all border-2 border-gray-200 border-dashed rounded-lg cursor-pointer h-80 hover:border-primary hover:bg-primary-50"
        [class.bg-primary-50]="isActive"
        [class.border-primary]="isActive"
        [class.bg-white]="!isActive"
        (click)="selectFile()"
        (dragover)="enter($event)"
        (dragleave)="leave($event)"
        (drop)="drop($event)"
      >
        <!-- Icon for file upload -->
        <app-icon
          name="ic:baseline-upload-file"
          class="w-8 h-8 text-primary"
        ></app-icon>
        <div class="flex flex-col gap-1 text-center">
          <!-- Instructions for file upload -->
          <p class="text-sm text-gray-800">
            동영상 파일을 드래그 할 수 있습니다.
          </p>
          <p class="text-sm text-gray-500">또는 클릭해서 선택이 가능합니다.</p>
        </div>
      </div>
      <!-- Display the count of items and maxlength -->
      <div class="flex w-full text-sm text-gray-500">
        <p>{{ items.length }} / {{ maxlength }}</p>
      </div>
    </div>

    <div
      class="flex flex-col gap-2 max-h-[23rem] overflow-auto"
      *ngIf="items.length > 0"
    >
      <!-- Display uploaded files -->
      <div
        class="flex w-full justify-between p-2.5 gap-2.5 bg-white border border-gray-200 rounded-md"
        *ngFor="let item of items"
      >
        <div class="flex gap-2.5 flex-col items-center w-full">
          <div class="flex items-end gap-4">
            <!-- Display status icons (success, fail) or a spinner for ongoing upload -->
            <app-icon
              class="w-5 h-5 text-red-500"
              name="ic:round-warning-amber"
              *ngIf="item.status === 'fail'"
            ></app-icon>
            <app-icon
              class="w-5 h-5 text-green-500"
              name="ic:baseline-check"
              *ngIf="item.status === 'success'"
            ></app-icon>
            <ion-spinner
              class="w-5 h-5"
              color="primary"
              *ngIf="!item.status"
            ></ion-spinner>
            <!-- Display delete icon for successful uploads -->
            <app-icon
              name="ic:outline-close"
              class="w-5 h-5 text-gray-700 cursor-pointer"
              *ngIf="item.status === 'success'"
              (click)="handleDelete(item)"
            ></app-icon>
          </div>
          <!-- Display file icon or image thumbnail -->
          <ng-container *ngIf="isVideoFile(item)">
            <!-- Only show video if thumbnail is not available -->
            <video
              [src]="item.url"
              class="w-full rounded-md cursor-pointer aspect-video"
              (click)="openVideo(item.url)"
              *ngIf="item.url"
            ></video>
            <div
              class="flex items-center justify-center p-2 border border-gray-200 rounded-md w-9 h-9"
              *ngIf="!item.url && !item.thumbnail"
            >
              <app-icon
                name="mdi:file-image"
                class="w-6 h-6 text-gray-700"
              ></app-icon>
            </div>
          </ng-container>

          <div class="flex flex-col gap-1">
            <!-- Display file name and size -->
            <p class="text-sm text-gray-700">{{ item.name }}</p>
            <p class="text-xs text-gray-400">{{ item.size | fileSize }}</p>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

1. 템플릿 예시 (자세한 설명은 주석을 참조하세요.)

 

 

 

import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, Input, OnInit, Optional, Self } from '@angular/core';
import { NgControl } from '@angular/forms';
import { IonicModule } from '@ionic/angular';
import { File } from '@namdorang/interface';
import { Subject } from 'rxjs';
import { FileSizePipe } from '../../pipes/file-size.pipe';
import { CustomValueAccessor, IconComponent, ToastService } from '@common/lib';

// FileUploadItem 타입 정의
type FileUploadItem = {
  name: string;
  url?: string;
  status?: 'success' | 'fail';
  thumbnail?: string; // 동영상 썸네일 이미지 URL
  size: number;
};

@Component({
  selector: 'app-file-upload',
  standalone: true,
  imports: [CommonModule, IconComponent, IonicModule, FileSizePipe],
  templateUrl: './file-upload.component.html',
  styleUrls: ['./file-upload.component.scss'],
})
export class FileUploadComponent
  extends CustomValueAccessor<File[]>
  implements OnInit
{
  @Input() label: string = ''; // 라벨 입력값을 받는 프로퍼티
  @Input() override required = false; // required 속성을 오버라이드하는 프로퍼티
  @Input() type: // 허용하는 파일 타입을 지정하는 프로퍼티
  'mp4' | 'avi' | 'mkv' | 'mov' | 'wmv' | 'flv' | '3gp' | 'mpeg' = 'mp4';
  @Input() maxlength: number = 1; // 최대 업로드 개수를 지정하는 프로퍼티
  @Input() minlength: number = 0; // 최소 업로드 개수를 지정하는 프로퍼티
  @Input() uploadedFiles$ = new Subject<File[] | null>(); // 업로드된 파일 목록을 방출하는 서브젝트

  accept = ''; // input 요소의 accept 속성 값
  fileServer = `${process.env['NX_FILE_SERVER_URL']}`; // 파일 서버 URL
  token = `${process.env['NX_FILE_SERVER_TOKEN']}`; // 파일 서버 토큰

  isLoading = false; // 업로드 중 여부를 나타내는 플래그
  isActive = false; // 드래그 앤 드롭 영역에 마우스가 진입한 상태를 나타내는 플래그

  items: FileUploadItem[] = []; // 업로드된 파일 목록을 담는 배열

  constructor(
    @Self() @Optional() public ngControl: NgControl, // 자체 제어 기능을 가진 폼 컨트롤
    private httpClient: HttpClient, // HTTP 요청을 수행하는 HttpClient
    private toastService: ToastService // 토스트 메시지를 표시하는 서비스
  ) {
    super();
    if (this.ngControl) this.ngControl.valueAccessor = this;
  }

  ngOnInit() {
    // 업로드된 파일 목록을 구독합니다.
    this.uploadedFiles$.subscribe({
      next: (files) => {
        this.items = [];
        if (!files) return;
        // 파일 목록을 FileUploadItem 형태로 변환하여 items 배열에 추가합니다.
        this.items = files.map((file) => {
          return {
            name: file.name,
            url: file.url,
            status: 'success',
            size: file.size,
          };
        });
      },
    });
  }

  // 드래그 앤 드롭 영역에 마우스가 진입했을 때 호출되는 메서드
  enter(ev: any) {
    this.isActive = true;
    // 이벤트의 기본 동작을 취소합니다. 드래그 앤 드롭 이벤트에서는 파일을 브라우저 창으로 드래그했을 때 파일이 열리는 동작을 막습니다.
    ev.preventDefault();
    // 이벤트의 전파를 중지합니다. 이는 부모 요소로 이벤트가 전파되지 않도록 막는 역할을 합니다.
    // 드래그 앤 드롭 영역에서 발생한 이벤트가 부모 요소로 전파되지 않도록 방지합니다.
    ev.stopPropagation();
  }

  // 드래그 앤 드롭 영역에서 마우스가 떠났을 때 호출되는 메서드
  leave(ev: any) {
    ev.preventDefault();
    ev.stopPropagation();
    this.isActive = false;
  }

  // 파일을 드롭했을 때 호출되는 메서드
  drop(ev: any) {
    ev.preventDefault();
    ev.stopPropagation();
    this.isActive = false;
    const files: FileList = ev.dataTransfer.files;
    if (files) {
      for (const file of Array.from(files)) {
        const formData = new FormData();
        formData.append('file', file);

        // 최대 업로드 개수를 검증합니다.
        const validateMaxlength = this.validateMaxlength();
        if (!validateMaxlength) {
          this.toastService.show(
            `파일은 최대 ${this.maxlength}개까지 업로드 가능합니다.`,
            'danger'
          );
          return;
        }

        // 파일 타입을 검증합니다.
        const validateType = this.validateType(file.type);
        if (!validateType) {
          this.toastService.show('동영상 파일만 업로드 가능합니다.', 'danger');
          return;
        }

        // 파일 중복을 검증합니다.
        const validate = this.duplicationValidate(file.name);
        if (validate) {
          this.toastService.show('이미 업로드된 파일입니다.', 'danger');
          return;
        }

        // 파일을 items 배열에 추가하고 업로드를 처리합니다.
        this.items.push({
          name: file.name,
          size: file.size,
        });
        this.handleUpload(formData, file);
      }
    }
  }

  // 최대 업로드 개수를 검증하는 메서드
  validateMaxlength(): boolean {
    const temp = this.items.filter((item) => item.status === 'success');

    if (temp.length >= this.maxlength) {
      return false;
    }
    return true;
  }

  // 파일 타입을 검증하는 메서드
  validateType(type: string): boolean {
    if (
      this.type === 'mp4' ||
      this.type === 'avi' ||
      this.type === 'mkv' ||
      this.type === 'mov' ||
      this.type === 'wmv' ||
      this.type === 'flv' ||
      this.type === '3gp' ||
      this.type === 'mpeg'
    ) {
      if (!type.includes('video')) {
        return false;
      }
    } else {
    }
    return true;
  }

  // 파일 중복을 검증하는 메서드
  duplicationValidate(name: string): boolean {
    if (this.items.findIndex((item) => item.name === name) !== -1) {
      return true;
    }
    return false;
  }

  // 값이 변경될 때 호출되는 메서드
  handleChange() {
    if (this.items && this.items.length > 0) {
      this.items.map((item) => {
        if (item.url) {
          if (!this.value) {
            this.value = [];
          }
          this.value.push({
            name: item.name,
            url: item.url,
            size: item.size,
          });
        }
      });
    } else {
      this.value = [];
    }
  }

  // 파일 삭제를 처리하는 메서드
  handleDelete(item: FileUploadItem) {
    const index = this.items.findIndex((i) => i.name === item.name);
    this.items.splice(index, 1);
    this.handleChange();
  }

  // 파일 업로드를 처리하는 메서드
  handleUpload(formData: FormData, file: any) {
    console.log(formData);
    const index = this.items.findIndex((item) => item.name === file.name);
    this.httpClient
      .post(`${this.fileServer}/upload?token=${this.token}`, formData)
      .subscribe({
        next: (res: any) => {
          if (res.ok) {
            this.items[index].url = this.fileServer + res.path;
            this.items[index].status = 'success';

            // 동영상 썸네일을 가져오는 작업 추가
            this.generateVideoThumbnail(this.items[index].url!)
              .then((thumbnailUrl) => {
                this.items[index].thumbnail = thumbnailUrl;
                this.handleChange();
              })
              .catch(() => {
                this.handleChange();
              });
          } else {
            this.items[index].status = 'fail';
          }
        },
        error: (err) => {
          this.items[index].status = 'fail';
        },
      });
  }

  // 이미지를 새 창에서 열기 위한 메서드
  openImage(url: string) {
    const a = document.createElement('a');
    a.href = url;
    a.target = '_blank';
    a.click();
  }

  // 파일 선택 창을 열기 위한 메서드
  selectFile() {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'mp4';
    input.multiple = true;
    input.onchange = () => {
      const files = input.files;
      if (files) {
        for (const file of Array.from(files)) {
          const formData = new FormData();
          formData.append('file', file);

          // 최대 업로드 개수를 검증합니다.
          const validateMaxlength = this.validateMaxlength();
          if (!validateMaxlength) {
            this.toastService.show(
              `파일은 최대 ${this.maxlength}개까지 업로드 가능합니다.`,
              'danger'
            );
            return;
          }

          // 파일 타입을 검증합니다.
          const validateType = this.validateType(file.type);
          if (!validateType) {
            this.toastService.show(
              '이미지 파일만 업로드 가능합니다.',
              'danger'
            );
            return;
          }

          // 파일 중복을 검증합니다.
          const validate = this.duplicationValidate(file.name);
          if (validate) {
            this.toastService.show('이미 업로드된 파일입니다.', 'danger');
            return;
          }

          // 파일을 items 배열에 추가하고 업로드를 처리합니다.
          this.items.push({
            name: file.name,
            size: file.size,
          });
          this.handleUpload(formData, file);
        }
      }
    };
    input.click();
  }

  // 동영상 썸네일을 생성하는 메서드
  generateVideoThumbnail(videoUrl: string): Promise<string> {
    return new Promise((resolve, reject) => {
      const video = document.createElement('video');
      video.src = videoUrl;
      video.setAttribute('crossorigin', 'anonymous');
      video.addEventListener('loadeddata', () => {
        const canvas = document.createElement('canvas');
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;
        canvas
          .getContext('2d')
          ?.drawImage(video, 0, 0, canvas.width, canvas.height);
        const thumbnailUrl = canvas.toDataURL(); // Base64 형식의 이미지 데이터 URL
        resolve(thumbnailUrl);
      });
      video.addEventListener('error', () => {
        reject();
      });
    });
  }

  // Check if the file is a video file
  isVideoFile(item: any): boolean {
    const videoExtensions = [
      'mp4',
      'avi',
      'mkv',
      'mov',
      'wmv',
      'flv',
      '3gp',
      'mpeg',
    ];
    const fileExtension = item.name.split('.').pop().toLowerCase();
    return videoExtensions.includes(fileExtension);
  }

  // Open the video in a new tab or window
  openVideo(url: string): void {
    window.open(url, '_blank');
  }
}

2. 파일 업로드 컴포넌트 예시

 

 

import { forwardRef, Type } from '@angular/core';
import {
  AbstractControl,
  ControlValueAccessor,
  NG_VALUE_ACCESSOR,
  ValidationErrors,
  Validator,
} from '@angular/forms';

export function createProviders<T>(type: Type<CustomValueAccessor<T>>) {
  return [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => type),
      multi: true,
    },
  ];
}

export class CustomValueAccessor<T> implements ControlValueAccessor, Validator {
  disabled = false;
  required: boolean = false;
  touched: boolean = false;
  onChange: any = (value: T) => {};
  onTouched: any = () => {};
  onValidationchange: any = () => {};

  private _value!: T;

  set value(newValue: T) {
    if (this._value === newValue) {
      return;
    }
    this._value = newValue;
    this.onChange(newValue);
    this.onTouched();
    this.touched = true;
  }

  get value(): T {
    return this._value;
  }

  writeValue(value: T): void {
    this.value = value;
  }

  registerOnChange(fn: (value: T) => {}): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: () => {}): void {
    this.onTouched = fn;
  }

  setDisabledState?(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  validate(control: AbstractControl<any, any>): ValidationErrors | null {
    return this.required && this.touched
      ? !this._value
        ? { required: true }
        : null
      : null;
  }

  registerOnValidatorChange?(fn: () => void): void {
    this.onValidationchange = fn;
  }
}

3. CustomValueAccessor : 앵귤러 제공 ControlValueAccessor 를 약간 수정한 버전입니다.

https://agnusdei1207.tistory.com/414

 

ControlValueAccessor Angular 사용 예시 / CustomValueAccessor

import { AbstractControl, ControlValueAccessor, ValidationErrors, Validator } from "@angular/forms"; export class CustomValueAccessor implements ControlValueAccessor, Validator { disabled = false; required: boolean = false; touched: boolean = false; onChan

agnusdei1207.tistory.com

 

 

4. IconComponent : 아이콘을 바로 활용할 수 있도록 커스텀된 컴포넌트

https://agnusdei1207.tistory.com/583

 

 

 

5. toastService : ionic 기반 토스트 메세지 활용을 커스텀 서비스

https://agnusdei1207.tistory.com/460

 

6. proess.env : webPack 활용

https://agnusdei1207.tistory.com/564

 

이로써 프론트엔드는 구현이 끝났습니다.

이제 프론트엔드에서 업로드한 동영상 파일을 백엔드 nestjs 에서 받도록 구현해보겠습니다.

 

7. 이름은 대략 video-upload 로 지어줍니다.

8. NX resource generate CLI 를 활용해서 생성합니다.

npx nx generate resource --project 프로젝트명 경로/파일명

 

 

 

import {
  Body,
  Controller,
  Logger,
  Post,
  Query,
  UploadedFile,
  UseInterceptors,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { Express } from 'express';
import { diskStorage } from 'multer';
import { join } from 'path';
import dayjs from 'dayjs';
import { VideoUploadService } from './video-upload.service';
import { File } from '@namdorang/interface';

@ApiTags('파일 업로드') // 파일 업로드 API에 대한 태그 정의
@Controller({ version: '1' }) // 버전 1의 컨트롤러로 설정
export class VideoUploadController {
  constructor(private readonly videoUploadService: VideoUploadService) {} // VideoUploadService 의존성 주입

  @Post() // POST 요청 핸들러
  @UseInterceptors(
    FileInterceptor('file', {
      // 'file'이라는 필드의 파일을 인터셉트함
      dest: join(__dirname, 'temp'), // 파일 저장 디렉토리 설정
      storage: diskStorage({
        destination: join(__dirname, 'temp'), // 파일 저장 경로 설정
        filename: (req, file, cb) => {
          // 파일 이름 설정 함수
          cb(null, `${dayjs().unix()}_${file.originalname}`); // 현재 시간과 파일 원본 이름을 조합하여 파일 이름 생성
        },
      }),
    })
  )
  @Post()
  @ApiOperation({
    summary: '비디오 업로드',
    description: '비디오 파일을 업로드합니다.',
  })
  upload(@UploadedFile() video: File[]): void {
    Logger.log('video-upload.controller.ts file');
    console.log(video); // 업로드된 비디오 파일 정보
  }

  // 업로드된 파일을 처리하는 핸들러
  uploadFile(@UploadedFile() file: Express.Multer.File) {
    return this.videoUploadService.upload(file); // VideoUploadService를 사용하여 파일 업로드 수행
  }
}

https://docs.nestjs.com/techniques/file-upload

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

9. nestjs 공식 문서 참조

 

 

 

 

import { BadGatewayException, Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import { join } from 'path';
import * as AWS from 'aws-sdk';
import { Express } from 'express';

@Injectable()
export class VideoUploadService {
  private logger: Logger = new Logger(VideoUploadService.name);

  async upload(file: Express.Multer.File) {
    const check = fs.readFileSync(join(__dirname, 'temp', file.filename)); // 파일을 동기적으로 읽어옴

    if (!check) {
      throw new BadGatewayException();
      // 파일이 존재하지 않으면 BadGatewayException 예외를 throw
    }

    const S3 = new AWS.S3({
      // AWS S3 클라이언트 생성
      endpoint: 'https://kr.object.ncloudstorage.com',
      region: 'kr-standard',
      credentials: {
        accessKeyId: process.env.NAVER_CLOUD_KEY,
        secretAccessKey: process.env.NAVER_CLOUD_SECRET,
      },
    });

    const result = await S3.putObject({
      // S3 버킷에 객체 업로드
      Bucket: 'aresa-assets',
      Key: file.filename, // 파일 이름
      ACL: 'public-read', // 공개 읽기 권한 설정
      Body: fs.createReadStream(join(__dirname, 'temp', file.filename)), // 파일의 스트림을 업로드
    }).promise();

    fs.rmSync(join(__dirname, 'temp', file.filename)); // 업로드 완료 후 임시 파일 삭제
    return `${process.env.NAVER_CLOUD_BUCKET_ENDPOINT}/${file.filename}`; // 업로드된 파일의 공개 URL 반환
  }
}

10. 서비스

11. 필요한 의존성 모듈 설치

https://www.npmjs.com/package/aws-sdk

 

aws-sdk

AWS SDK for JavaScript. Latest version: 2.1400.0, last published: 9 hours ago. Start using aws-sdk in your project by running `npm i aws-sdk`. There are 20203 other projects in the npm registry using aws-sdk.

www.npmjs.com

https://www.npmjs.com/package/fs

 

fs

This package name is not currently in use, but was formerly occupied by another package. To avoid malicious use, npm is hanging on to the package name, but loosely, and we'll probably give it to you if you want it.. Latest version: 0.0.1-security, last pub

www.npmjs.com

https://www.npmjs.com/package/multer

 

multer

Middleware for handling `multipart/form-data`.. Latest version: 1.4.5-lts.1, last published: a year ago. Start using multer in your project by running `npm i multer`. There are 3802 other projects in the npm registry using multer.

www.npmjs.com

 

반응형
LIST