├── .gitattributes ├── .github └── pull-request-template.md ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── README.md ├── backend ├── .gitignore ├── README.md ├── nest-cli.json ├── package.json ├── public │ └── test.html ├── src │ ├── app.module.ts │ ├── common │ │ ├── auth │ │ │ ├── auth.guard.ts │ │ │ ├── auth.module.ts │ │ │ └── auth.service.ts │ │ ├── cache │ │ │ ├── cache.module.ts │ │ │ └── cache.service.ts │ │ ├── constant.ts │ │ ├── decorator │ │ │ └── hide-in-production.decorator.ts │ │ ├── exception │ │ │ ├── axios-formatter.ts │ │ │ ├── errors.ts │ │ │ └── filters.ts │ │ ├── logger │ │ │ ├── config.ts │ │ │ └── middleware.ts │ │ ├── request │ │ │ ├── request.guard.ts │ │ │ ├── request.interceptor.ts │ │ │ ├── request.module.ts │ │ │ └── request.service.ts │ │ └── types │ │ │ ├── request.ts │ │ │ └── session.ts │ ├── main.ts │ ├── quiz │ │ ├── quiz.controller.spec.ts │ │ ├── quiz.controller.ts │ │ ├── quiz.entity.ts │ │ ├── quiz.module.ts │ │ ├── quiz.service.spec.ts │ │ └── quiz.service.ts │ └── sandbox │ │ ├── constant.ts │ │ ├── exec.api.ts │ │ ├── pipes │ │ ├── command.pipe.spec.ts │ │ └── command.pipe.ts │ │ ├── sandbox.controller.ts │ │ ├── sandbox.module.ts │ │ ├── sandbox.service.spec.ts │ │ ├── sandbox.service.ts │ │ ├── types │ │ └── elements.ts │ │ └── utils.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json ├── eslint.config.mjs ├── frontend ├── index.html ├── package.json ├── postcss.config.js ├── src │ ├── App.tsx │ ├── api │ │ ├── quiz.ts │ │ └── timer.ts │ ├── assets │ │ ├── containerLifeCycle.png │ │ ├── docker-architecture.webp │ │ ├── docker-container.png │ │ ├── dropDown.svg │ │ ├── favicon.ico │ │ ├── logo.png │ │ ├── quiz-page-red-box.png │ │ └── visualization-demo.gif │ ├── components │ │ ├── ErrorPage.tsx │ │ ├── Header.tsx │ │ ├── PageInfoArea.tsx │ │ ├── PageType.tsx │ │ ├── Sidebar.tsx │ │ ├── StartButton.tsx │ │ ├── StopButton.tsx │ │ ├── Timer.tsx │ │ ├── TimerArea.tsx │ │ ├── modals │ │ │ ├── QuizSubmitResultModal.tsx │ │ │ └── TimerModal.tsx │ │ ├── popover │ │ │ ├── ContainerPopover.tsx │ │ │ └── ImagePopover.tsx │ │ ├── quiz │ │ │ ├── QuizButtons.tsx │ │ │ ├── QuizDescription.tsx │ │ │ ├── QuizInputBox.tsx │ │ │ ├── QuizNodes.tsx │ │ │ ├── QuizPage.tsx │ │ │ ├── QuizPageWrapper.tsx │ │ │ ├── QuizSubmitArea.tsx │ │ │ ├── VisualizationNodes.tsx │ │ │ └── XTerminal.tsx │ │ ├── staticpages │ │ │ ├── DockerContainerLifeCyclePage.tsx │ │ │ ├── DockerContainerPage.tsx │ │ │ ├── DockerImagePage.tsx │ │ │ ├── DockerPage.tsx │ │ │ └── LandingPage.tsx │ │ └── visualization │ │ │ ├── Arrow.tsx │ │ │ ├── ArrowAnimation.tsx │ │ │ ├── BaseNode.tsx │ │ │ ├── ContainerNode.tsx │ │ │ └── DockerVisualization.tsx │ ├── constant │ │ ├── hostStatus.ts │ │ ├── quiz.ts │ │ ├── sidebarStatus.ts │ │ ├── timer.ts │ │ ├── visualization.ts │ │ └── xterm.ts │ ├── handlers │ │ └── handler.ts │ ├── hooks │ │ ├── useAlert.ts │ │ ├── useDockerVisualization.ts │ │ ├── useHostStatus.ts │ │ ├── useQuizData.ts │ │ ├── useSidebar.ts │ │ └── useTerminal.ts │ ├── index.css │ ├── main.tsx │ ├── types │ │ ├── quiz.ts │ │ ├── sidebar.ts │ │ ├── timer.ts │ │ └── visualization.ts │ ├── utils │ │ ├── LoadingTerminal.ts │ │ ├── sidebarUtils.ts │ │ ├── terminalUtils.ts │ │ └── visualizationUtils.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── package.json ├── pnpm-workspace.yaml └── sandbox ├── host-container ├── Dockerfile └── docker-compose.yml ├── quiz-images ├── hello-world │ ├── Dockerfile │ └── hello.c └── joke │ ├── Dockerfile │ └── joke.c ├── registry └── docker-compose.yml ├── setup.ps1 └── setup.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.* text eol=lf 4 | 5 | *.sln text eol=crlf 6 | 7 | *.png binary 8 | *.jpg binary 9 | *.jpeg binary 10 | *.gif binary 11 | *.ico binary 12 | *.mov binary 13 | *.mp4 binary 14 | *.mp3 binary 15 | *.flv binary 16 | *.fla binary 17 | *.swf binary 18 | *.gz binary 19 | *.zip binary 20 | *.7z binary 21 | *.ttf binary 22 | *.eot binary 23 | *.woff binary 24 | *.pyc binary 25 | *.pdf binary 26 | *.ez binary 27 | *.bz2 binary 28 | *.swp binary 29 | *.cur binary 30 | *.wav binary 31 | *.webp binary 32 | *.woff2 binary -------------------------------------------------------------------------------- /.github/pull-request-template.md: -------------------------------------------------------------------------------- 1 | ## 작업 개요 2 | 3 | > 해결하려는 작업에 대해 간결하게 설명해주세요. 4 | > issue 번호를 걸어주세요. 5 | 6 | ## 작업 상세 내용 7 | 8 | - [ ] TODO 9 | - [ ] TODO 10 | - [ ] TODO 11 | 12 | ## 참고자료(선택) 13 | 14 | ## 문제점 혹은 고민(선택) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | .env.prod 82 | .env.production 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # Docusaurus cache and generated files 110 | .docusaurus 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | 134 | package-lock.json 135 | pnpm-lock.yaml -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package.json 3 | package-lock.json 4 | *.md 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "jsxSingleQuote": true 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://github.com/user-attachments/assets/ada3afb4-cfc9-4bb4-8a41-ecb4a83e258a) 2 | 3 |
4 |

🐳 LearnDocker 🐳

5 |

Docker에 대해서 알고싶으신가요? 샌드박스 환경을 통해 단계별 학습을 해봅시다. 시각화는 덤입니다!

6 |

7 | LearnDocker 홈페이지 8 |

9 |

10 | 📚 프로젝트 위키 11 |   |   12 | 🎨 디자인 13 |   |   14 | 📋 백로그 15 |

16 |
17 | 18 | # 🚀 프로젝트 개요 19 | ## 💡 LearnDocker란 무엇인가? 20 | > LearnDocker는 웹 브라우저만으로 Docker의 핵심 개념과 명령어를 학습할 수 있는 온라인 플랫폼입니다. 21 | > 22 | 실제 Docker 환경의 동작을 실시간 애니메이션으로 보여주며, 직관적인 시각화를 통해 복잡한 Docker의 개념을 쉽게 이해할 수 있습니다. 23 |

24 | 📅 개발 기간: 2024-10-28(월) ~ 25 | 26 | ## 🎯 프로젝트를 시작하게 된 계기 27 | “Docker를 처음 접하는 사람이Docker 설치를 안하고 안전한 환경에서 Docker 명령어들을 학습할 수는 없을까?”, “내가 작성한 명령어에 따라 실시간으로 업데이트되는 도커 환경을 시각화해서 볼 수는 없을까?”. 이런 고민을 해결하기 위해 LearnDocker 서비스를 시작하게 되었습니다.

28 | Play with Docker는 안전한 환경을 제공하지만 체계적인 학습 커리큘럼이 없고, 명령어 실행 결과를 시각적으로 확인할 수 없다는 한계가 있습니다. 또한 단순히 실행 환경만 제공할 뿐, 사용자의 학습 진도나 이해도를 확인할 수 있는 상호작용이 부족합니다. LearnDocker는 이러한 한계점들을 개선하여 더 나은 Docker 학습 경험을 제공하고자 합니다.
29 | 30 | > LearnDocker 서비스는 Play with Docker의 한계를 넘어선 docker 계의 code academy를 목표로 합니다 31 | > 32 | 33 | # ✨ 주요 기능 34 | ## 🐋 개별 도커 실습 환경 제공 35 | > 사용자는 "학습 시작하기" 버튼만 누르면 자신만의 샌드박스 환경을 안전하고 편리하게 제공 받을 수 있습니다. 36 | > 37 | ![도커 실습 환경](https://github.com/user-attachments/assets/a29e48f5-19b0-433b-bfbe-24185dcef13e) 38 | 39 | ## 🎯 실시간 시각화 40 | > 사용자는 자신의 명령어가 컨테이너와 이미지에 어떤 영향을 미치는지 실시간으로 볼 수 있습니다. 41 | > 42 | ![최종_시각화_03](https://github.com/user-attachments/assets/b7e34106-4f71-4974-8ce2-1790e63e1581) 43 | 44 | 45 | ## 🔄 퀴즈 풀이 46 | > 사용자는 주어진 문제를 읽고 도커 명령어를 입력하여 문제를 풀고 채점할 수 있습니다. 47 | > 48 | ![최종_시각화_04](https://github.com/user-attachments/assets/fa8a079b-6e81-45f8-ab87-dda7af3d5c3b) 49 | 50 | # 🤔기술적 도전 51 | 52 | ## docker 실행 가능한 격리된 환경 제공 53 | 54 | ![image](https://github.com/user-attachments/assets/ca1cd109-f573-4be4-938d-02bc25ecac34) 55 | 출처) https://speakerdeck.com/kakao/github-actions-runner-bildeu-siljeon-jeogyonggi?slide=37 56 | 57 | 사용자에게 격리된 환경을 효율적으로 제공하기 위해 컨테이너 기술을 사용하고자 하였으나, Docker 컨테이너 안에서 Docker를 실행시키는 목적을 달성시키는 명확히 더 나은 해결책이 없었습니다. 58 | 59 | Docker 컨테이너 내부에서 Docker를 실행시키는 방법으로 DinD(Docker in Docker)와 DooD(Docker out of Docker)가 있다는 것을 알아내었습니다. DinD는 보안 위험성이 존재했고, DooD는 사용자별로 완전히 격리된 환경을 제공하지 못한다는 단점이 있었습니다. 서비스의 목적을 달성하기 위해 DinD를 선택하고, 사용자에게 제한된 명령어만 허용하는 방식을 적용하기로 결정했습니다. 60 | 61 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/docker-%EC%8B%A4%ED%96%89-%EA%B0%80%EB%8A%A5%ED%95%9C-%EA%B2%A9%EB%A6%AC%EB%90%9C-%ED%99%98%EA%B2%BD-%EC%A0%9C%EA%B3%B5) 62 | > 63 | 64 | ## 악의적인 명령어 필터링 65 | 66 | ![image](https://github.com/user-attachments/assets/98e86f77-b86f-4c97-854f-283ee151f72f) 67 | 68 | 컨테이너 내부에서 호스트에 접근이 가능한 DinD 방식의 특성으로 인해 발생하는 보안 위험성을 막아야 합니다. `docker run -v /:/host ubuntu cat /host/etc/passwd` 와 같은 방식으로 호스트 환경에 접근이 가능합니다. 69 | 70 | 컨테이너에 전달되는 명령어를 검사하는 프록시 서버를 두어, 위험한 명령어를 식별하고 요청을 거부하도록 만들었습니다. 71 | 72 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/%EC%95%85%EC%9D%98%EC%A0%81%EC%9D%B8-%EB%AA%85%EB%A0%B9%EC%96%B4-%ED%95%84%ED%84%B0%EB%A7%81) 73 | > 74 | 75 | ## 시각화 설계 및 구현 76 | 77 | 저희 모두 리액트가 처음인 백엔드 개발자였습니다. 또한 도커 명령어에 대한 시각화는 인터넷에 참고할 만한 사례를 찾기 어려워서 어떻게 설계를 해야할지 부터 고민이 있었습니다. 78 | 79 | 네부캠에서 배운 대로 시각화 문제를 최대한 작게 쪼개고 체크리스트를 만들면서 해결 했습니다. 부족한 리액트 지식은 구현-검토-개선의 반복을 통해 보완했고, Learning by doing 방식으로 실제 개발을 진행하면서 배웠습니다. 커스텀 훅, useRef, react-query 등의 새로운 기능을 알게 될 때마다 적용하며 점진적으로 리팩토링을 수행했습니다. 잘못된 개념으로 인해 발생한 버그들이 있었지만 원인을 찾고 해결하는 과정에서 오히려 더 깊은 이해를 얻는 계기가 되었습니다. 80 | 81 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/시각화) 82 | > 83 | 84 | ## 서버에서 사용자 데이터 관리 85 | 86 | 서버에서 사용자의 연결 정보를 관리하는 방식으로 세션과 토큰 중 어떤 것을 사용해야 할까에 대해서 고민이 되었습니다. 87 | 88 | 저희 서비스는 각 사용자마다 도커 컨테이너를 하나씩 할당해주기 때문에 리소스 관리가 매우 중요합니다. 사용자의 상태에 따라 컨테이너를 주기적으로 정리할 필요가 있었고, 사용자의 상태를 서버가 알기 위해서는 세션을 활용해야 한다고 판단하여 세션 방식을 채택하였습니다. 89 | 90 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/[2024‐11‐03]-팀-회의) 91 | > 92 | 93 | ## 17초 지연 이슈 해결 94 | 95 | ![예전 docker 명령 오류 메시지](https://github.com/user-attachments/assets/e5b96ffd-8454-40dc-b3de-c14850c3e12c) 96 | 97 | 사용자가 학습 시작 버튼을 누르고 터미널 환경에 정상적인 docker 명령어 요청을 보내기까지 17초의 지연 시간이 발생했습니다. docker daemon이 api 요청을 거부하는 상태가 발생했기 때문이었는데, 처음에는 daemon 자체의 문제라고 판단하고 어떻게 17초 동안 사용자 경험을 개선할 수 있을지 고민했습니다. 98 | 99 | 사용자가 도커 호스트 컨테이너가 준비중이라는 것을 알 수 있도록 터미널에 로딩 화면을 보여주고 SSE(Server Sent Event)로 컨테이너 준비가 완료되었다는 것을 알려주었습니다. 100 | 하지만 근본적으로 사용자가 17초동안 기다려야 한다는 사실은 변하지 않았는데, docker daemon의 로그를 분석해본 결과 의도적으로 startup이 지연된다는 것을 확인했습니다. docker daemon이 기본적으로 https를 수신하도록 설정되어 있기 때문이었는데, 별도로 —tls=false 옵션을 추가하여 지연시간을 3초 이내로 단축시킬 수 있었습니다. 101 | 102 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/17초-지연-이슈-해결) 103 | > 104 | 105 | ## 악성 docker image 다운로드 제한 106 | 107 | ![image](https://github.com/user-attachments/assets/521a6c3d-20c7-4053-b640-ea83040f8995) 108 | 109 | 외부에서 가져온 악의적인 실행 파일로 컨테이너를 탈출하거나 서버 리소스를 과하게 사용하는 일을 막아야 합니다. 개인이 만든 퍼블릭 레지스트리에서 이미지를 다운로드하는 것을 막지 못하고 있었습니다. 110 | 111 | 컨테이너가 실행되는 샌드박스 서버를 프라이빗 서브넷에 두어 외부 네트워크와의 연결을 차단하는 방식으로 문제를 해결했습니다. 112 | 113 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/%EC%95%85%EC%84%B1-docker-image-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-%EC%A0%9C%ED%95%9C) 114 | > 115 | 116 | ## 상호작용이 없는 사용자의 리소스 정리 117 | 118 | ![image](https://github.com/user-attachments/assets/0a2c60fb-6e36-4336-9b5d-b142382ded99) 119 | 120 | 각각의 사용자에게는 도커 컨테이너를 하나씩 할당합니다. 사용자가 컨테이너를 할당받고, 학습 종료버튼을 누르지 않고(세션 종료 및 컨테이너 해제) 탭을 닫아버린다면, 도커 컨테이너는 세션 만료시간이 될 때까지 서버의 리소스를 점유하게 됩니다. 121 | 122 | 위 문제를 해결하기 위해서 사용자가 서버에 보낸 모든 요청 시간을 세션 테이블에 저장합니다. 세션을 주기적으로 정리해주는 세션 청소기를 구현하였습니다. 세션 청소기는 세션에 기록된 사용자의 마지막 요청 시간 기준 30분을 초과하면 해당 세션을 삭제하며, 사용자에게 할당된 도커 컨테이너 또한 삭제합니다. 123 | 124 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/[5주-6일차-‐-J048-김영관]-개발-일지(탭-닫을-시-세션-해제-및-세션-관리2)) 125 | > 126 | 127 | ## 만료된 세션에 대한 서버 리소스 정리 128 | 129 | ![image](https://github.com/user-attachments/assets/98f77df5-f27e-458b-8ab1-e7ef6dbaa893) 130 | 131 | 세션 테이블에 저장된 사용자의 세션 정보들 중 만료된 세션을 해제하고, 세션에 해당하는 도커 컨테이너를 삭제해야 합니다. 132 | 133 | 상호작용이 없는 사용자의 리소스 정리 의 해결 방법과 동일하게 세션 청소기에 기능을 더 추가하는 방식으로 해결하였습니다. 세션 청소기는 세션 테이블을 10분 주기마다 돌면서 만료된 세션에 대해서 세션 테이블에서 삭제하며, 세션에 매핑된 도커 컨테이너 또한 해제합니다. 134 | 135 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/[5주-2일차-‐-J048-김영관]-개발-일지(세션해제-및-IP를-통한-세션관리)) 136 | > 137 | 138 | ## 악의적 사용자의 연속 요청에 대한 리소스 관리 139 | 140 | ![image](https://github.com/user-attachments/assets/a864a4c1-c783-4e4d-9ff9-9ab071ff8aca) 141 | 142 | 악의적인 사용자가 서버에 짧은 시간에 많은 요청을 보내게 된다면 서버의 부하는 증가하게 됩니다. 143 | 144 | 위의 문제를 방지하기 위해 세션 테이블에 저장된 마지막 요청 시간을 기준으로 0.5초 내에 발생한 요청에 대해서 Block합니다. 145 | 146 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/[5주-3일차-‐-J048-김영관]-개발-일지(연속요청-처리-및-세션관리)) 147 | > 148 | 149 | ## 다수의 컨테이너를 할당받으려는 악의적 사용자 차단 150 | 151 | ![image](https://github.com/user-attachments/assets/ce6a757c-fc53-412c-b4cb-2a3c71fb76ee) 152 | 153 | 악의적 사용자가 웹 브라우저 여러개를 활용해 도커 컨테이너를 할당받을 수 있는 문제가 있었습니다. 이런 악성 사용자가 많이 발생하게 되면, 서버의 리소스 점유율은 증가하게 되고, 정상적인 사용자가 사용할 수 없는 문제가 발생하리라 생각이 들었습니다. 154 | 155 | 사용자의 도커 컨테이너를 할당하는 트리거는 학습 시작 버튼 을 눌렀을 때 해당 클라이언트가 세션이 할당됐는지 여부에 따라서 도커 컨테이너를 할당해줍니다. 결국 쿠키에 저장된 세션이 없고 버튼만 누르면 컨테이너가 할당되는 상황이었습니다. 이를 방지하기 위해서 요청을 보낸 클라이언트의 IP를 해시화하여 세션 테이블에 저장하고 사용자가 쿠키를 삭제하고 다시 접속하더라도 IP를 기반으로 이전에 생성된 컨테이너를 할당해주도록 하였습니다. 156 | 157 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/[5주-2일차-‐-J048-김영관]-개발-일지(세션해제-및-IP를-통한-세션관리)) 158 | > 159 | 160 | ## 명령창에 나오는 알 수 없는 문자에 대한 처리 161 | 162 | 가끔 사용자 명령창에 알 수 없는 문자가 같이 출력되는 현상이 있었습니다. 163 | 164 | ![이상한 문자 01](https://github.com/user-attachments/assets/37d1ff4f-1c3c-43e8-98bc-81dcd50cf75c) 165 | 166 | ![이상한 문자 02](https://github.com/user-attachments/assets/de12f08d-8748-40ed-9663-b2e4cc1698af) 167 | 168 | 169 | docker api tty 옵션에 따라 알 수 없는 문자가 다르게 나오는 것을 파악했습니다. 문제를 해결하기 위하여 docker api 공식문서를 참고해서 원인을 파악하였고 터미널 제어문자를 처리하기 위해 xterm.js를 도입했습니다. 170 | 171 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/[4주-3일차-‐-J278-홍규선]-학습-일지(docker-api-exec-tty-옵션)) 172 | > 173 | 174 | ## 커스텀 이미지 컨테이너 stop시 타임 아웃 나는 문제 175 | 176 | 커스텀 이미지 중 joke의 컨테이너 중지 명령어 실행 시 응답 대기 시간이 10초를 초과하여 타임아웃이 발생하는 문제가 있었습니다. 이는 배포 환경뿐만 아니라 로컬 환경에서도 동일하게 발생했습니다. 177 | 178 | Docker의 컨테이너 종료 프로세스를 분석한 결과, SIGTERM 신호를 처리하지 못하면 SIGKILL을 보낸다는 것을 알게 되었습니다. joke Dockerfile에 SIGTERM 시그널 핸들링을 구현하여 정상적인 종료가 가능하도록 해결했습니다. 179 | 180 | > 자세히 보기: [관련 개발 기록](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/joke-이미지-컨테이너-stop시-타임-아웃-나는-문제) [](https://github.com/boostcampwm-2024/web34-LearnDocker/wiki/joke-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-stop%EC%8B%9C-%ED%83%80%EC%9E%84-%EC%95%84%EC%9B%83-%EB%82%98%EB%8A%94-%EB%AC%B8%EC%A0%9C) 181 | > 182 | 183 | 184 | # ⚒️ 기술 스택 185 | | 분류 | 기술 | 186 | | ---- | ---- | 187 | | 🎨 프론트엔드 | | 188 | | 🔧 백엔드 | | 189 | | 🔨 공통 | | 190 |
191 | 192 | # 🏗️ 아키텍처 설계 193 | ## 간단한 서버 구조 194 | ![image](https://github.com/user-attachments/assets/41d78b17-9b07-4195-930c-aa10d2cfcd87) 195 | 196 | ## 백엔드 서버 아키텍처 197 | ![아키텍처2](https://github.com/user-attachments/assets/0b9fda36-8628-4a59-a1c7-b431a7b32b60) 198 | 199 | 200 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | Donate us 19 | Support us 20 | Follow us on Twitter 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Project setup 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Compile and run the project 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Run tests 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Deployment 62 | 63 | When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. 64 | 65 | If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: 66 | 67 | ```bash 68 | $ npm install -g mau 69 | $ mau deploy 70 | ``` 71 | 72 | With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. 73 | 74 | ## Resources 75 | 76 | Check out a few resources that may come in handy when working with NestJS: 77 | 78 | - Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. 79 | - For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). 80 | - To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). 81 | - Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. 82 | - Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). 83 | - Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). 84 | - To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). 85 | - Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). 86 | 87 | ## Support 88 | 89 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 90 | 91 | ## Stay in touch 92 | 93 | - Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) 94 | - Website - [https://nestjs.com](https://nestjs.com/) 95 | - Twitter - [@nestframework](https://twitter.com/nestframework) 96 | 97 | ## License 98 | 99 | Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). 100 | -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "dotenvx run -f ../.env.production -- nest start", 12 | "start:dev": "dotenvx run -f ../.env -- nest start --watch --preserveWatchOutput", 13 | "start:debug": "dotenvx run -f ../.env -- nest start --debug", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@dotenvx/dotenvx": "^1.23.0", 24 | "@nestjs/axios": "^3.1.2", 25 | "@nestjs/common": "^10.0.0", 26 | "@nestjs/core": "^10.0.0", 27 | "@nestjs/platform-express": "^10.0.0", 28 | "@nestjs/schedule": "^4.1.1", 29 | "@nestjs/serve-static": "^4.0.2", 30 | "@nestjs/typeorm": "^10.0.2", 31 | "axios": "^1.7.7", 32 | "cookie-parser": "^1.4.7", 33 | "mysql2": "^3.11.4", 34 | "nest-winston": "^1.9.7", 35 | "reflect-metadata": "^0.2.2", 36 | "rxjs": "^7.8.1", 37 | "typeorm": "^0.3.20", 38 | "winston": "^3.17.0", 39 | "winston-daily-rotate-file": "^5.0.0" 40 | }, 41 | "devDependencies": { 42 | "@nestjs/cli": "^10.0.0", 43 | "@nestjs/schematics": "^10.0.0", 44 | "@nestjs/testing": "^10.0.0", 45 | "@tsconfig/node20": "^20.1.4", 46 | "@types/cookie-parser": "^1.4.7", 47 | "@types/express": "^5.0.0", 48 | "@types/jest": "^29.5.2", 49 | "@types/node": "^20.3.1", 50 | "@types/supertest": "^6.0.0", 51 | "jest": "^29.5.0", 52 | "source-map-support": "^0.5.21", 53 | "supertest": "^7.0.0", 54 | "ts-jest": "^29.1.0", 55 | "ts-loader": "^9.4.3", 56 | "ts-node": "^10.9.1", 57 | "tsconfig-paths": "^4.2.0", 58 | "typescript": "^5.1.3" 59 | }, 60 | "jest": { 61 | "moduleFileExtensions": [ 62 | "js", 63 | "json", 64 | "ts" 65 | ], 66 | "rootDir": "src", 67 | "testRegex": ".*\\.spec\\.ts$", 68 | "transform": { 69 | "^.+\\.(t|j)s$": "ts-jest" 70 | }, 71 | "collectCoverageFrom": [ 72 | "**/*.(t|j)s" 73 | ], 74 | "coverageDirectory": "../coverage", 75 | "testEnvironment": "node" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /backend/public/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test 7 | 8 | 9 |

Test입니다.

10 | 11 | 12 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { QuizModule } from './quiz/quiz.module'; 4 | import { ServeStaticModule } from '@nestjs/serve-static'; 5 | import { join } from 'path'; 6 | import { SandboxModule } from './sandbox/sandbox.module'; 7 | import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; 8 | import { 9 | BusinessExceptionsFilter, 10 | HttpExceptionsFilter, 11 | LastExceptionFilter, 12 | } from './common/exception/filters'; 13 | import { AuthModule } from './common/auth/auth.module'; 14 | import { LoggerMiddleware } from './common/logger/middleware'; 15 | import { ScheduleModule } from '@nestjs/schedule'; 16 | import { RequestModule } from './common/request/request.module'; 17 | import { RequestInterceptor } from './common/request/request.interceptor'; 18 | import { CacheModule } from './common/cache/cache.module'; 19 | 20 | @Module({ 21 | imports: [ 22 | TypeOrmModule.forRoot({ 23 | type: 'mysql', 24 | host: process.env.MYSQL_HOST, 25 | port: process.env.MYSQL_PORT ? parseInt(process.env.MYSQL_PORT) : 3306, 26 | username: process.env.MYSQL_USER, 27 | password: process.env.MYSQL_PASSWORD, 28 | database: process.env.MYSQL_DATABASE, 29 | autoLoadEntities: true, 30 | synchronize: true, 31 | }), 32 | QuizModule, 33 | SandboxModule, 34 | ServeStaticModule.forRoot({ 35 | rootPath: join(__dirname, '..', '..', 'frontend', 'dist'), 36 | exclude: ['/api*'], 37 | renderPath: '/*', 38 | }), 39 | AuthModule, 40 | RequestModule, 41 | ScheduleModule.forRoot(), 42 | CacheModule, 43 | ], 44 | providers: [ 45 | { 46 | provide: APP_FILTER, 47 | useClass: LastExceptionFilter, 48 | }, 49 | { 50 | provide: APP_FILTER, 51 | useClass: HttpExceptionsFilter, 52 | }, 53 | { 54 | provide: APP_FILTER, 55 | useClass: BusinessExceptionsFilter, 56 | }, 57 | Logger, 58 | { 59 | provide: APP_INTERCEPTOR, 60 | useClass: RequestInterceptor, 61 | }, 62 | ], 63 | }) 64 | export class AppModule implements NestModule { 65 | configure(consumer: MiddlewareConsumer) { 66 | consumer.apply(LoggerMiddleware).forRoutes({ path: '*', method: RequestMethod.ALL }); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /backend/src/common/auth/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext, Logger } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | 4 | @Injectable() 5 | export class AuthGuard implements CanActivate { 6 | private readonly Logger = new Logger(AuthGuard.name); 7 | 8 | constructor(private readonly authService: AuthService) {} 9 | 10 | canActivate(context: ExecutionContext) { 11 | const request = context.switchToHttp().getRequest(); 12 | 13 | const sessionId = request.cookies['sid']; 14 | this.Logger.debug(`Session ID: ${sessionId}`); 15 | 16 | const session = this.authService.validateSession(sessionId); 17 | 18 | request.session = session; 19 | 20 | return true; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/common/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { CacheModule } from '../cache/cache.module'; 4 | import { AuthGuard } from './auth.guard'; 5 | 6 | @Global() 7 | @Module({ 8 | imports: [CacheModule], 9 | providers: [AuthService, AuthGuard], 10 | exports: [AuthGuard, AuthService], 11 | }) 12 | export class AuthModule {} 13 | -------------------------------------------------------------------------------- /backend/src/common/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { CacheService } from '../cache/cache.service'; 3 | import { SESSION_DURATION } from '../constant'; 4 | import { InvalidSessionException, SessionAlreadyAssignedException } from '../exception/errors'; 5 | 6 | @Injectable() 7 | export class AuthService { 8 | constructor(private readonly cacheService: CacheService) {} 9 | 10 | validateSession(sessionId?: string) { 11 | if (sessionId == null) { 12 | throw new InvalidSessionException(); 13 | } 14 | const session = this.cacheService.get(sessionId); 15 | if (session == null) { 16 | throw new InvalidSessionException(); 17 | } 18 | if (new Date().getTime() - session.startTime.getTime() > SESSION_DURATION) { 19 | throw new InvalidSessionException(); 20 | } 21 | return session; 22 | } 23 | 24 | throwIfSessionIsValid(hashedIpAddress: string, sessionId?: string) { 25 | try { 26 | if (sessionId) this.validateSession(sessionId); 27 | else this.validateSession(hashedIpAddress); 28 | throw new SessionAlreadyAssignedException(); 29 | } catch (error) { 30 | if (!(error instanceof InvalidSessionException)) { 31 | throw error; 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/src/common/cache/cache.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CacheService } from './cache.service'; 3 | import { HttpModule } from '@nestjs/axios'; 4 | 5 | @Module({ 6 | imports: [ 7 | HttpModule.register({ 8 | timeout: 10000, 9 | }), 10 | ], 11 | providers: [CacheService], 12 | exports: [CacheService], 13 | }) 14 | export class CacheModule {} 15 | -------------------------------------------------------------------------------- /backend/src/common/cache/cache.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { UserSession } from '../types/session'; 3 | import { SESSION_DURATION, NO_INTERACTION_TIME_LIMIT } from '../constant'; 4 | import { Cron, CronExpression } from '@nestjs/schedule'; 5 | import { HttpService } from '@nestjs/axios'; 6 | 7 | @Injectable() 8 | export class CacheService { 9 | private readonly store = new Map(); 10 | 11 | constructor(private readonly httpService: HttpService) {} 12 | 13 | get(key: string) { 14 | const session = this.store.get(key); 15 | return session != null ? { ...session } : null; 16 | } 17 | 18 | set(key: string, value: UserSession) { 19 | this.store.set(key, value); 20 | } 21 | 22 | updateLevel(key: string, level: number) { 23 | const session = this.store.get(key); 24 | if (session != null) { 25 | session.level = level; 26 | } 27 | } 28 | 29 | delete(key: string) { 30 | this.store.delete(key); 31 | } 32 | 33 | @Cron(CronExpression.EVERY_10_MINUTES) 34 | deleteExpiredSession() { 35 | const currentTime = new Date(); 36 | this.store.forEach(async (values, sessionId) => { 37 | const startTime = new Date(values.startTime); 38 | if ( 39 | startTime.getTime() + SESSION_DURATION < currentTime.getTime() || 40 | currentTime.getTime() > values.lastRequest.getTime() + NO_INTERACTION_TIME_LIMIT 41 | ) { 42 | const { containerId } = this.get(sessionId) as UserSession; 43 | await this.httpService.axiosRef.delete( 44 | `${process.env.SANDBOX_URL}/containers/${containerId}?force=true&v=true` 45 | ); 46 | 47 | this.delete(sessionId); 48 | } 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/common/constant.ts: -------------------------------------------------------------------------------- 1 | export const SESSION_DURATION = 1000 * 60 * 60 * 4; // 4 hours 2 | export const NO_INTERACTION_TIME_LIMIT = 1000 * 60 * 30; // 30 minutes 3 | -------------------------------------------------------------------------------- /backend/src/common/decorator/hide-in-production.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException, UseGuards, applyDecorators } from '@nestjs/common'; 2 | import { Injectable, CanActivate } from '@nestjs/common'; 3 | 4 | @Injectable() 5 | class HideInProductionGuard implements CanActivate { 6 | 7 | canActivate(): boolean { 8 | if (process.env.NODE_ENV === 'production') { 9 | throw new ForbiddenException('This API is only available in development mode.'); 10 | } 11 | return true; 12 | } 13 | } 14 | 15 | // eslint-disable-next-line @typescript-eslint/naming-convention 16 | export const HideInProduction = () => { 17 | return applyDecorators( 18 | UseGuards(HideInProductionGuard), 19 | ); 20 | } -------------------------------------------------------------------------------- /backend/src/common/exception/axios-formatter.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | export function formatAxiosError(error: AxiosError) { 4 | return { 5 | message: error.message, 6 | stack: error.stack, 7 | cause: error.cause, 8 | code: error.code, 9 | url: error.config?.url, 10 | method: error.config?.method, 11 | status: error.response?.status, 12 | responseData: error.response?.data, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/common/exception/errors.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export class BusinessException extends Error { 4 | constructor(message: string) { 5 | super(message); 6 | } 7 | } 8 | 9 | // 이해를 돕기 위한 샘플로 만들었음. 필요없으면 삭제 가능 10 | export class PreviousProblemUnsolvedExeption extends BusinessException { 11 | constructor() { 12 | super('모든 이전 문제를 풀지 않으면 이번 문제를 풀 수 없음'); 13 | } 14 | } 15 | 16 | export class SessionAlreadyAssignedException extends BusinessException { 17 | constructor() { 18 | super('이미 세션에 컨테이너가 할당되어 있음'); 19 | } 20 | } 21 | 22 | export class EntityNotExistException extends BusinessException { 23 | constructor(entityName: string) { 24 | super(`${entityName}(이)가 DB에 존재하지 않음`); 25 | } 26 | } 27 | 28 | export class InvalidSessionException extends BusinessException { 29 | constructor() { 30 | super('유효하지 않은 세션입니다'); 31 | } 32 | } 33 | 34 | export class RequestIntervalException extends BusinessException { 35 | constructor() { 36 | super('너무 짧은 간격의 요청입니다.'); 37 | } 38 | } 39 | 40 | export class TooManyRequestsException extends HttpException { 41 | constructor() { 42 | super('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/src/common/exception/filters.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExceptionFilter, 3 | Catch, 4 | ArgumentsHost, 5 | HttpException, 6 | HttpStatus, 7 | ForbiddenException, 8 | Logger, 9 | NotFoundException, 10 | UnauthorizedException, 11 | } from '@nestjs/common'; 12 | import { HttpAdapterHost } from '@nestjs/core'; 13 | import { 14 | BusinessException, 15 | EntityNotExistException, 16 | InvalidSessionException, 17 | PreviousProblemUnsolvedExeption, 18 | SessionAlreadyAssignedException, 19 | TooManyRequestsException, 20 | RequestIntervalException, 21 | } from './errors'; 22 | import { isAxiosError } from 'axios'; 23 | import { formatAxiosError } from './axios-formatter'; 24 | 25 | @Catch() 26 | export class LastExceptionFilter implements ExceptionFilter { 27 | protected readonly logger = new Logger(this.constructor.name); 28 | 29 | constructor(private readonly httpAdapterHost: HttpAdapterHost) {} 30 | 31 | catch(exception: unknown, host: ArgumentsHost) { 32 | const { httpAdapter } = this.httpAdapterHost; 33 | 34 | const ctx = host.switchToHttp(); 35 | 36 | const httpStatus = 37 | exception instanceof HttpException 38 | ? exception.getStatus() 39 | : HttpStatus.INTERNAL_SERVER_ERROR; 40 | 41 | const responseBody = { 42 | statusCode: httpStatus, 43 | timestamp: new Date().toISOString(), 44 | path: httpAdapter.getRequestUrl(ctx.getRequest()), 45 | message: exception instanceof HttpException ? exception.message : undefined, 46 | }; 47 | 48 | if (this.constructor.name === 'LastExceptionFilter') { 49 | if (isAxiosError(exception)) { 50 | this.logger.error(formatAxiosError(exception)); 51 | } else { 52 | this.logger.error(exception); 53 | } 54 | } 55 | 56 | httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus); 57 | } 58 | } 59 | 60 | @Catch(HttpException) 61 | export class HttpExceptionsFilter extends LastExceptionFilter { 62 | catch(exception: HttpException, host: ArgumentsHost) { 63 | this.logger.debug(exception); 64 | super.catch(exception, host); 65 | } 66 | } 67 | 68 | @Catch(BusinessException) 69 | export class BusinessExceptionsFilter extends LastExceptionFilter { 70 | catch(exception: unknown, host: ArgumentsHost) { 71 | this.logger.debug(exception); 72 | if (exception instanceof SessionAlreadyAssignedException) { 73 | super.catch(new ForbiddenException(exception), host); 74 | return; 75 | } 76 | 77 | if (exception instanceof EntityNotExistException) { 78 | super.catch(new NotFoundException(exception), host); 79 | return; 80 | } 81 | 82 | if (exception instanceof InvalidSessionException) { 83 | super.catch(new UnauthorizedException(exception), host); 84 | return; 85 | } 86 | 87 | if (exception instanceof PreviousProblemUnsolvedExeption) { 88 | super.catch(new ForbiddenException(exception), host); 89 | return; 90 | } 91 | 92 | if (exception instanceof RequestIntervalException) { 93 | super.catch(new TooManyRequestsException(), host); 94 | return; 95 | } 96 | super.catch(exception, host); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /backend/src/common/logger/config.ts: -------------------------------------------------------------------------------- 1 | import { utilities as nestWinstonModuleUtilities, WinstonModuleOptions } from 'nest-winston'; 2 | import * as winston from 'winston'; 3 | import DailyRotateFile from 'winston-daily-rotate-file'; 4 | 5 | const productionFormat = winston.format.combine( 6 | winston.format.timestamp({ 7 | format: 'YY-MM-DD HH:mm:ss', 8 | }), 9 | winston.format.ms(), 10 | nestWinstonModuleUtilities.format.nestLike('SandBoxProxy', { 11 | prettyPrint: true, 12 | colors: false, 13 | }) 14 | ); 15 | const developmentFormat = winston.format.combine( 16 | winston.format.timestamp({ 17 | format: 'YY-MM-DD HH:mm:ss', 18 | }), 19 | winston.format.ms(), 20 | nestWinstonModuleUtilities.format.nestLike('SandBoxProxy', { 21 | prettyPrint: true, 22 | colors: true, 23 | }) 24 | ); 25 | 26 | const productionTransports = [ 27 | // Info 레벨 로그 28 | new DailyRotateFile({ 29 | level: 'info', 30 | dirname: 'logs/log', 31 | filename: '%DATE%.log', 32 | datePattern: 'YY-MM-DD_HH', 33 | zippedArchive: false, 34 | maxSize: '20m', 35 | frequency: '3h', 36 | maxFiles: '14d', 37 | format: productionFormat, 38 | 39 | }), 40 | // Error 레벨 로그 41 | new DailyRotateFile({ 42 | level: 'error', 43 | dirname: 'logs/error', 44 | filename: '%DATE%.log', 45 | datePattern: 'YY-MM-DD_HH', 46 | zippedArchive: false, 47 | maxSize: '20m', 48 | frequency: '3h', 49 | maxFiles: '14d', 50 | format: productionFormat, 51 | }), 52 | ]; 53 | 54 | const developmentTransports = [ 55 | new winston.transports.Console({ 56 | level: 'debug', 57 | format: developmentFormat, 58 | }), 59 | ]; 60 | 61 | const winstonConfig: WinstonModuleOptions = { 62 | transports: 63 | process.env.NODE_ENV === 'production' ? productionTransports : developmentTransports, 64 | }; 65 | 66 | export { winstonConfig }; 67 | -------------------------------------------------------------------------------- /backend/src/common/logger/middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | 4 | @Injectable() 5 | export class LoggerMiddleware implements NestMiddleware { 6 | private readonly logger = new Logger(); 7 | 8 | use(req: Request, res: Response, next: NextFunction) { 9 | const { ip, originalUrl, method, headers, body, cookies } = req; 10 | 11 | this.logger.log( 12 | `IP: ${ip}, URL: ${originalUrl}, Method: ${method}, ${JSON.stringify( 13 | { 14 | headers, 15 | body, 16 | cookies, 17 | }, 18 | null, 19 | 2 20 | )}` 21 | ); 22 | 23 | next(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/common/request/request.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common'; 2 | import { RequestService } from './request.service'; 3 | import { RequestIntervalException } from '../exception/errors'; 4 | @Injectable() 5 | export class RequestGuard implements CanActivate { 6 | private readonly Logger = new Logger(RequestGuard.name); 7 | 8 | constructor(private readonly requestService: RequestService) {} 9 | 10 | canActivate(context: ExecutionContext): boolean { 11 | const request = context.switchToHttp().getRequest(); 12 | 13 | const sessionId = request.cookies?.['sid']; 14 | if (sessionId) { 15 | try { 16 | const result = this.requestService.validRequestInterval(sessionId); 17 | return result; 18 | } catch (error) { 19 | if (error instanceof RequestIntervalException) { 20 | this.Logger.debug('Request Blocked'); 21 | throw new RequestIntervalException(); 22 | } 23 | } 24 | } 25 | 26 | return false; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/common/request/request.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { CacheService } from '../cache/cache.service'; 3 | import { UserSession } from '../types/session'; 4 | 5 | @Injectable() 6 | export class RequestInterceptor implements NestInterceptor { 7 | constructor(private readonly cacheService: CacheService) {} 8 | intercept(context: ExecutionContext, next: CallHandler) { 9 | const request = context.switchToHttp().getRequest(); 10 | const sessionId = request.cookies['sid']; 11 | if ( 12 | context.getClass().name !== 'SandboxController' || 13 | context.getHandler().name !== 'assignContainer' 14 | ) { 15 | const sessionDatas = this.cacheService.get(sessionId) as UserSession; 16 | const currentTime = new Date(); 17 | this.cacheService.set(sessionId, { 18 | ...sessionDatas, 19 | lastRequest: currentTime, 20 | }); 21 | } 22 | return next.handle(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/common/request/request.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { RequestService } from './request.service'; 3 | import { RequestGuard } from './request.guard'; 4 | import { CacheModule } from '../cache/cache.module'; 5 | import { RequestInterceptor } from '../request/request.interceptor'; 6 | 7 | @Global() 8 | @Module({ 9 | imports: [CacheModule], 10 | providers: [RequestService, RequestGuard, RequestInterceptor], 11 | exports: [RequestGuard, RequestService, RequestInterceptor], 12 | }) 13 | export class RequestModule {} 14 | -------------------------------------------------------------------------------- /backend/src/common/request/request.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { CacheService } from '../cache/cache.service'; 3 | import { UserSession } from '../types/session'; 4 | import { RequestIntervalException } from '../exception/errors'; 5 | 6 | const LIMIT = 500; 7 | 8 | @Injectable() 9 | export class RequestService { 10 | constructor(private readonly cacheService: CacheService) {} 11 | validRequestInterval(sessionId: string) { 12 | const sessionDatas = this.cacheService.get(sessionId) as UserSession; 13 | const prevReqTime = sessionDatas.lastRequest.getTime(); 14 | const currentReqTime = new Date(); 15 | const interval = currentReqTime.getTime() - prevReqTime; 16 | if (interval < LIMIT) { 17 | throw new RequestIntervalException(); 18 | } 19 | this.cacheService.set(sessionId, { 20 | ...sessionDatas, 21 | lastRequest: currentReqTime, 22 | }); 23 | return true; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/common/types/request.ts: -------------------------------------------------------------------------------- 1 | import { UserSession } from './session'; 2 | import { Request } from 'express'; 3 | 4 | export interface RequestWithSession extends Request { 5 | session: UserSession; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/common/types/session.ts: -------------------------------------------------------------------------------- 1 | export interface UserSession { 2 | sessionId: string; 3 | containerId: string; 4 | containerPort: string; 5 | startTime: Date; 6 | renew: boolean; 7 | level: number; 8 | lastRequest: Date; 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { NestExpressApplication } from '@nestjs/platform-express'; 3 | import { AppModule } from './app.module'; 4 | import { winstonConfig } from './common/logger/config'; 5 | import { WinstonModule } from 'nest-winston'; 6 | import cookieParser from 'cookie-parser'; 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule, { 10 | logger: WinstonModule.createLogger(winstonConfig), 11 | bufferLogs: true, 12 | }); 13 | app.set('trust proxy', true); 14 | app.use(cookieParser()); 15 | app.setGlobalPrefix('api'); 16 | await app.listen(process.env.PORT ?? 3000); 17 | } 18 | bootstrap(); 19 | -------------------------------------------------------------------------------- /backend/src/quiz/quiz.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { QuizController } from './quiz.controller'; 3 | 4 | describe('QuizController', () => { 5 | let controller: QuizController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [QuizController], 10 | }).compile(); 11 | 12 | controller = module.get(QuizController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /backend/src/quiz/quiz.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, ParseIntPipe, Query, Req, UseGuards } from '@nestjs/common'; 2 | import { QuizService } from './quiz.service'; 3 | import { AuthGuard } from '../common/auth/auth.guard'; 4 | import { RequestWithSession } from '../common/types/request'; 5 | 6 | @Controller('quiz') 7 | export class QuizController { 8 | constructor(private quizService: QuizService) {} 9 | 10 | @Get('/:id') 11 | @UseGuards(AuthGuard) 12 | getQuizById(@Param('id', ParseIntPipe) quizId: number, @Req() req: RequestWithSession) { 13 | const { level } = req.session; 14 | this.quizService.accessQuiz(level, quizId); 15 | return this.quizService.getQuizById(quizId); 16 | } 17 | 18 | @Get('/:id/submit') 19 | @UseGuards(AuthGuard) 20 | submitQuiz( 21 | @Param('id', ParseIntPipe) quizId: number, 22 | @Req() req: RequestWithSession, 23 | @Query('userAnswer') userAnswer?: string 24 | ) { 25 | const { sessionId, containerPort, level } = req.session; 26 | this.quizService.accessQuiz(level, quizId); 27 | return this.quizService.submitQuiz(quizId, sessionId, containerPort, level, userAnswer); 28 | } 29 | 30 | @Get('/:id/access') 31 | @UseGuards(AuthGuard) 32 | accessQuiz(@Param('id', ParseIntPipe) quizId: number, @Req() req: RequestWithSession) { 33 | const { level } = req.session; 34 | this.quizService.accessQuiz(level, quizId); 35 | return; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/src/quiz/quiz.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity() 4 | export class Quiz { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column('varchar', { length: 64 }) 9 | title: string; 10 | 11 | @Column('varchar', { length: 500 }) 12 | content: string; 13 | 14 | @Column('varchar', { length: 500, nullable: true }) 15 | hint?: string; 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/quiz/quiz.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { QuizController } from './quiz.controller'; 3 | import { QuizService } from './quiz.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Quiz } from './quiz.entity'; 6 | import { SandboxModule } from '../sandbox/sandbox.module'; 7 | import { CacheModule } from '../common/cache/cache.module'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Quiz]), SandboxModule, CacheModule], 11 | controllers: [QuizController], 12 | providers: [QuizService], 13 | }) 14 | export class QuizModule {} 15 | -------------------------------------------------------------------------------- /backend/src/quiz/quiz.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { QuizService } from './quiz.service'; 3 | 4 | describe('QuizService', () => { 5 | let service: QuizService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [QuizService], 10 | }).compile(); 11 | 12 | service = module.get(QuizService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /backend/src/sandbox/constant.ts: -------------------------------------------------------------------------------- 1 | export const HOST_STATUS = { 2 | STARTING: 'STARTING', 3 | READY: 'READY', 4 | } as const; 5 | 6 | export type HostStatus = (typeof HOST_STATUS)[keyof typeof HOST_STATUS]; 7 | -------------------------------------------------------------------------------- /backend/src/sandbox/exec.api.ts: -------------------------------------------------------------------------------- 1 | import { HttpService } from '@nestjs/axios'; 2 | 3 | // 어떤 명령어가 들어오느냐에 따라 다르게 4 | export async function requestDockerCommand( 5 | httpService: HttpService, 6 | containerId: string, 7 | command: Array 8 | ) { 9 | const exec = await httpService.axiosRef.post( 10 | `${process.env.SANDBOX_URL}/containers/${containerId}/exec`, 11 | { 12 | AttachStdin: false, 13 | AttachStdout: true, 14 | AttachStderr: true, 15 | Tty: true, 16 | Cmd: command, 17 | } 18 | ); 19 | const response = await httpService.axiosRef.post( 20 | `${process.env.SANDBOX_URL}/exec/${exec.data.Id}/start`, 21 | { 22 | Detach: false, 23 | Tty: true, 24 | } 25 | ); 26 | 27 | return response.data; 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/sandbox/pipes/command.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CommandValidationPipe } from './command.pipe'; 3 | import { BadRequestException } from '@nestjs/common'; 4 | 5 | describe('CommandValidationPipe', () => { 6 | let pipe: CommandValidationPipe; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | providers: [CommandValidationPipe], 11 | }).compile(); 12 | pipe = module.get(CommandValidationPipe); 13 | process.env.JOKE_IMAGE_ID = 'e970cf9c277f'; 14 | }); 15 | 16 | it.each([ 17 | 'docker ps | grep my_container', 18 | 'docker stop my_container; docker rm my_container', 19 | 'docker build -t my_image . && docker run my_image', 20 | 'docker start my_container || docker run my_container', 21 | "docker insepct -f '{{.State.Running}}' $(docker ps -q)", 22 | 'docker run $DOCKER_IMAGE', 23 | 'docker ps > ps.txt', 24 | 'docker exec -i container sh < setup.sh', 25 | 'docker run image 2> error.txt', 26 | 'docker run image &> output.txt', 27 | 'docker exec -it docker', 28 | ])('Invalid user command test', (command: string) => { 29 | expect(() => { 30 | pipe.transform(command); 31 | }).toThrow(BadRequestException); 32 | }); 33 | 34 | it.each([ 35 | 'docker pull hello-world', 36 | 'docker rmi hello-world', 37 | 'docker run hello-world', 38 | 'docker images', 39 | 'docker ps -a', 40 | 'docker start hello-world', 41 | 'docker stop hello-world', 42 | 'docker restart hello-world', 43 | 'docker rm hello-world', 44 | ])('Valid user command test', (command: string) => { 45 | expect(pipe.transform(command)).toEqual(command); 46 | }); 47 | 48 | describe('Joke Image ID tests', () => { 49 | it.each([ 50 | 'docker run -d learndocker.io/joke', 51 | 'docker run --detach learndocker.io/joke', 52 | 'docker run -d -p 8080 e', 53 | 'docker run -d e97', 54 | 'docker run --detach -p 8080 e970cf9c277f', 55 | 'docker run -d e970', 56 | ])('Valid joke image command test with detach option', (command: string) => { 57 | expect(pipe.transform(command)).toEqual(command); 58 | }); 59 | 60 | it.each([ 61 | 'docker run learndocker.io/joke', 62 | 'docker run e', 63 | 'docker run e97', 64 | 'docker run -p 8080 e970cf9c277f', 65 | 'docker run e970', 66 | ])('Invalid joke image command test without detach option', (command: string) => { 67 | expect(() => { 68 | pipe.transform(command); 69 | }).toThrow(BadRequestException); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /backend/src/sandbox/pipes/command.pipe.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; 2 | 3 | const VALID_REGEX = /^docker\s(?:(?!.*\b(?:sh|bash|exec|tag|commit)\b|.*[;&|<>$`]))/; 4 | const VOLUME_MOUNT_CHECK_REGEX = 5 | /^docker\s+(?:(?:container\s+)?(?:create|run))\s+.*(?:-v\b|--volume\b)/; 6 | const PUSH_CHECK_REGEX = /^docker\s+(?:(?:image\s+)?(?:push))/; 7 | const DOCKER_RUN_CHECK_REGEX = /^docker\s+(?:(?:container\s+)?(?:run))/; 8 | const DETACH_OPTION_REGEX = /(?:-d\b|--detach\b)/; 9 | const JOKE_IMAGE_NAME = 'learndocker.io/joke'; 10 | 11 | @Injectable() 12 | export class CommandValidationPipe implements PipeTransform { 13 | transform(value: string) { 14 | value = value.trim(); 15 | const baseValidation = value.match(VALID_REGEX); 16 | const volumeMountCheck = VOLUME_MOUNT_CHECK_REGEX.test(value); 17 | const pushCheck = PUSH_CHECK_REGEX.test(value); 18 | const isValid = baseValidation && !volumeMountCheck && !pushCheck; 19 | 20 | if (!isValid) { 21 | throw new BadRequestException('해당 명령어는 사용이 불가능합니다!'); 22 | } 23 | 24 | const runImage = DOCKER_RUN_CHECK_REGEX.test(value); 25 | if (runImage && this.isJokeImage(value)) { 26 | const hasDetachOption = DETACH_OPTION_REGEX.test(value); 27 | if (!hasDetachOption) { 28 | throw new BadRequestException( 29 | 'joke 이미지는 detach 옵션(-d 또는 --detach)과 함께 실행해야 합니다!' 30 | ); 31 | } 32 | } 33 | 34 | return value; 35 | } 36 | 37 | private isJokeImage(commands: string): boolean { 38 | const jokeImageId = process.env.JOKE_IMAGE_ID; 39 | 40 | if (commands.includes(JOKE_IMAGE_NAME)) { 41 | return true; 42 | } 43 | if (jokeImageId && commands.split(' ').some((command) => jokeImageId.startsWith(command))) { 44 | return true; 45 | } 46 | 47 | return false; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /backend/src/sandbox/sandbox.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Body, Delete, Req, Res, UseGuards } from '@nestjs/common'; 2 | import { SandboxService } from './sandbox.service'; 3 | import { CommandValidationPipe } from './pipes/command.pipe'; 4 | import { Request, Response } from 'express'; 5 | import { SESSION_DURATION } from '../common/constant'; 6 | import { HideInProduction } from '../common/decorator/hide-in-production.decorator'; 7 | import { AuthGuard } from '../common/auth/auth.guard'; 8 | import { AuthService } from '../common/auth/auth.service'; 9 | import { RequestWithSession } from '../common/types/request'; 10 | import { SessionAlreadyAssignedException } from 'src/common/exception/errors'; 11 | import { getHashValueFromIP } from '../sandbox/utils'; 12 | import { RequestGuard } from 'src/common/request/request.guard'; 13 | import { CacheService } from 'src/common/cache/cache.service'; 14 | 15 | @Controller('sandbox') 16 | export class SandboxController { 17 | constructor( 18 | private readonly sandboxService: SandboxService, 19 | private readonly authService: AuthService, 20 | private readonly cacheService: CacheService 21 | ) {} 22 | 23 | @Get('elements') 24 | @UseGuards(AuthGuard) 25 | getUserContainersImages(@Req() req: RequestWithSession) { 26 | const { containerPort } = req.session; 27 | return this.sandboxService.getUserContainerImages(containerPort); 28 | } 29 | 30 | @Post('command') 31 | @UseGuards(AuthGuard, RequestGuard) 32 | processUserCommand( 33 | @Req() req: RequestWithSession, 34 | @Body('command', CommandValidationPipe) command: string 35 | ) { 36 | const { containerId } = req.session; 37 | return this.sandboxService.processUserCommand(command, containerId); 38 | } 39 | 40 | @Post('start') 41 | async assignContainer(@Req() req: Request, @Res({ passthrough: true }) res: Response) { 42 | const sessionId = req.cookies['sid']; 43 | const ipAddress = req.ip as string; 44 | const hashedSessionID = getHashValueFromIP(ipAddress); 45 | try { 46 | this.authService.throwIfSessionIsValid(hashedSessionID, sessionId); 47 | const sessionData = await this.sandboxService.assignContainer(ipAddress); 48 | const startTime = sessionData?.startTime as Date; 49 | res.cookie('sid', sessionData?.sessionId, { httpOnly: true, maxAge: SESSION_DURATION }); 50 | return { endDate: new Date(startTime).getTime() + SESSION_DURATION }; 51 | } catch (error) { 52 | if (error instanceof SessionAlreadyAssignedException) { 53 | const startTime = this.cacheService.get(hashedSessionID)?.startTime as Date; 54 | res.cookie('sid', hashedSessionID, { httpOnly: true, maxAge: SESSION_DURATION }); 55 | return { endDate: new Date(startTime).getTime() + SESSION_DURATION }; 56 | } else { 57 | throw error; 58 | } 59 | } 60 | } 61 | 62 | @Get('hostStatus') 63 | @UseGuards(AuthGuard) 64 | getHostStatus(@Req() req: RequestWithSession) { 65 | const { containerPort } = req.session; 66 | return this.sandboxService.getHostStatus(containerPort); 67 | } 68 | 69 | @Get('endDate') 70 | @UseGuards(AuthGuard) 71 | getMaxAge(@Req() req: RequestWithSession) { 72 | const { startTime } = req.session; 73 | const endDate = new Date(startTime.getTime() + SESSION_DURATION); 74 | return { endDate }; 75 | } 76 | 77 | @Delete('release') 78 | @UseGuards(AuthGuard) 79 | releaseUserSession(@Req() req: Request, @Res({ passthrough: true }) res: Response) { 80 | const sessionId = req.cookies['sid']; 81 | this.sandboxService.releaseUserSession(sessionId); 82 | res.clearCookie('sid'); 83 | } 84 | 85 | // 개발용 API입니다. 배포 시 노출되면 안됩니다. 86 | @Delete('clear') 87 | @HideInProduction() 88 | async clearContainers() { 89 | await this.sandboxService.clearContainers(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /backend/src/sandbox/sandbox.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SandboxService } from './sandbox.service'; 3 | import { SandboxController } from './sandbox.controller'; 4 | import { HttpModule } from '@nestjs/axios'; 5 | import { CacheModule } from '../common/cache/cache.module'; 6 | 7 | @Module({ 8 | imports: [ 9 | HttpModule.register({ 10 | timeout: 10000, 11 | }), 12 | CacheModule, 13 | ], 14 | providers: [SandboxService], 15 | controllers: [SandboxController], 16 | exports: [SandboxService], 17 | }) 18 | export class SandboxModule {} 19 | -------------------------------------------------------------------------------- /backend/src/sandbox/sandbox.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SandboxService } from './sandbox.service'; 3 | 4 | describe('Sandbox service', () => { 5 | let service: SandboxService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [SandboxService], 10 | }).compile(); 11 | 12 | service = module.get(SandboxService); 13 | }); 14 | 15 | it('Container and images are responded', async () => { 16 | service.getUserContainerImages('ddd'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /backend/src/sandbox/sandbox.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpService } from '@nestjs/axios'; 2 | import { Injectable, Logger } from '@nestjs/common'; 3 | import { getHashValueFromIP } from './utils'; 4 | import { requestDockerCommand } from './exec.api'; 5 | import { Container, Image } from './types/elements'; 6 | import { CacheService } from '../common/cache/cache.service'; 7 | import { formatAxiosError } from '../common/exception/axios-formatter'; 8 | import { isAxiosError } from 'axios'; 9 | import { HOST_STATUS } from './constant'; 10 | import { UserSession } from 'src/common/types/session'; 11 | 12 | @Injectable() 13 | export class SandboxService { 14 | private readonly logger = new Logger(SandboxService.name); 15 | 16 | constructor( 17 | private readonly httpService: HttpService, 18 | private readonly cacheService: CacheService 19 | ) {} 20 | 21 | async getUserContainerImages(containerPort: string) { 22 | try { 23 | const imageResponse = await this.getUserImages(containerPort); 24 | const images = this.parseImages(imageResponse); 25 | 26 | const containerResponse = await this.getUserContainers(containerPort); 27 | const containers = this.parseContainers(containerResponse); 28 | return { images, containers }; 29 | } catch (error) { 30 | if (isAxiosError(error)) { 31 | this.logger.error(formatAxiosError(error)); 32 | } 33 | return { images: [], containers: [] }; 34 | } 35 | } 36 | 37 | async getUserImages(containerPort: string) { 38 | const response = await this.httpService.axiosRef.get( 39 | `http://${process.env.SANDBOX_HOST}:${containerPort}/images/json` 40 | ); 41 | return response.data; 42 | } 43 | 44 | async getUserContainers(containerPort: string) { 45 | const response = await this.httpService.axiosRef.get( 46 | `http://${process.env.SANDBOX_HOST}:${containerPort}/containers/json?all=true` 47 | ); 48 | return response.data; 49 | } 50 | 51 | parseImages(imageData: Array>) { 52 | return imageData.reduce>((imageReducer, image) => { 53 | imageReducer.push({ 54 | id: image.Id, 55 | name: image.RepoTags[0].split(':')[0], 56 | }); 57 | return imageReducer; 58 | }, []); 59 | } 60 | 61 | parseContainers(containerData: Array>) { 62 | return containerData.reduce>((containerReducer, container) => { 63 | containerReducer.push({ 64 | id: container.Id, 65 | name: container.Names[0].split('/')[1], 66 | image: container.Image, 67 | status: container.State, 68 | }); 69 | return containerReducer; 70 | }, []); 71 | } 72 | 73 | async getHostStatus(containerPort: string) { 74 | try { 75 | await this.httpService.axiosRef.get( 76 | `http://${process.env.SANDBOX_HOST}:${containerPort}/_ping` 77 | ); 78 | return HOST_STATUS.READY; 79 | } catch (error) { 80 | if (isAxiosError(error)) { 81 | return HOST_STATUS.STARTING; 82 | } 83 | throw error; 84 | } 85 | } 86 | 87 | async processUserCommand(command: string, containerId: string) { 88 | return requestDockerCommand(this.httpService, containerId, command.split(' ')); 89 | } 90 | 91 | async createContainer() { 92 | const requestBody = { 93 | Image: 'dind', 94 | HostConfig: { 95 | Privileged: true, 96 | PortBindings: { 97 | '2375/tcp': [ 98 | { 99 | HostPort: '0', 100 | HostIp: '0.0.0.0', 101 | }, 102 | ], 103 | }, 104 | ExtraHosts: ['learndocker.io:172.17.0.1'], 105 | }, 106 | Env: ['DOCKER_TLS_CERTDIR='], 107 | Cmd: ['--tls=false'], 108 | }; 109 | const { data } = await this.httpService.axiosRef.post( 110 | `${process.env.SANDBOX_URL}/containers/create`, 111 | requestBody 112 | ); 113 | return data.Id; 114 | } 115 | 116 | async getContainerPort(containerId: string) { 117 | const { data } = await this.httpService.axiosRef.get( 118 | `${process.env.SANDBOX_URL}/containers/${containerId}/json` 119 | ); 120 | return data.NetworkSettings.Ports['2375/tcp'][0].HostPort; 121 | } 122 | 123 | async startContainer(containerId: string) { 124 | return this.httpService.axiosRef.post( 125 | `${process.env.SANDBOX_URL}/containers/${containerId}/start` 126 | ); 127 | } 128 | 129 | async assignContainer(ipAddress: string) { 130 | const containerId = await this.createContainer(); 131 | let containerPort; 132 | 133 | try { 134 | await this.startContainer(containerId); 135 | containerPort = await this.getContainerPort(containerId); 136 | } catch (startError) { 137 | try { 138 | await this.deleteContainer(containerId); 139 | } catch (deleteError) { 140 | this.logger.error(deleteError); 141 | } 142 | throw startError; 143 | } 144 | 145 | const newSessionId = getHashValueFromIP(ipAddress); 146 | this.cacheService.set(newSessionId, { 147 | sessionId: newSessionId, 148 | containerId, 149 | containerPort, 150 | renew: false, 151 | startTime: new Date(), 152 | level: 1, 153 | lastRequest: new Date(), 154 | }); 155 | 156 | this.logger.log( 157 | `Container Assigned: ${containerId}\t Session: ${newSessionId} \t Port: ${containerPort}` 158 | ); 159 | 160 | return this.cacheService.get(newSessionId); 161 | } 162 | 163 | async deleteContainer(containerId: string) { 164 | await this.httpService.axiosRef.delete( 165 | `${process.env.SANDBOX_URL}/containers/${containerId}?force=true` 166 | ); 167 | } 168 | 169 | async getContainers() { 170 | const { data } = await this.httpService.axiosRef.get( 171 | `${process.env.SANDBOX_URL}/containers/json?all=true` 172 | ); 173 | return data; 174 | } 175 | 176 | async clearContainers() { 177 | const containers = await this.getContainers(); 178 | await Promise.all( 179 | containers.map((container: { Id: string }) => this.deleteContainer(container.Id)) 180 | ); 181 | } 182 | 183 | async releaseUserSession(sessionId: string) { 184 | const { containerId, containerPort } = this.cacheService.get(sessionId) as UserSession; 185 | 186 | await this.httpService.axiosRef.delete( 187 | `${process.env.SANDBOX_URL}/containers/${containerId}?force=true&v=true` 188 | ); 189 | this.cacheService.delete(sessionId); 190 | 191 | this.logger.log( 192 | `Container Released: ${containerId}\t Session: ${sessionId} \t Port: ${containerPort}` 193 | ); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /backend/src/sandbox/types/elements.ts: -------------------------------------------------------------------------------- 1 | export type Container = { 2 | id: string; 3 | name: string; 4 | status: string; 5 | image: string; 6 | }; 7 | 8 | export type Image = { 9 | id: string; 10 | name: string; 11 | }; 12 | 13 | export type ContainerData = { 14 | Command: string; 15 | CreatedAt: string; 16 | ID: string; 17 | Image: string; 18 | Labels: string; 19 | LocalVolumes: string; 20 | Mounts: string; 21 | Names: string; 22 | Networks: string; 23 | Ports: string; 24 | RunningFor: string; 25 | Size: string; 26 | State: string; 27 | Status: string; 28 | }; 29 | 30 | export type ImageData = { 31 | Containers: string; 32 | CreatedAt: string; 33 | CreatedSince: string; 34 | Digest: string; 35 | ID: string; 36 | Repository: string; 37 | SharedSize: string; 38 | Size: string; 39 | Tag: string; 40 | UniqueSize: string; 41 | VirtualSize: string; 42 | }; 43 | -------------------------------------------------------------------------------- /backend/src/sandbox/utils.ts: -------------------------------------------------------------------------------- 1 | import { Image, Container, ImageData, ContainerData } from './types/elements'; 2 | import crypto from 'crypto'; 3 | 4 | export function parseStringToJson(datas: string) { 5 | return datas.split('\n').reduce>((jsonDatas, line) => { 6 | if (line !== '') jsonDatas.push(JSON.parse(line)); 7 | return jsonDatas; 8 | }, []); 9 | } 10 | 11 | export function filterContainerInfo(containers: Array) { 12 | return containers.reduce>((containerReducer, container) => { 13 | containerReducer.push({ 14 | id: container.ID, 15 | name: container.Names, 16 | status: container.State, 17 | image: container.Image, 18 | }); 19 | return containerReducer; 20 | }, []); 21 | } 22 | 23 | export function filterImageInfo(images: Array) { 24 | return images.reduce>((imageReducer, image) => { 25 | imageReducer.push({ 26 | id: image.ID, 27 | name: image.Repository, 28 | }); 29 | return imageReducer; 30 | }, []); 31 | } 32 | 33 | export function getHashValueFromIP(ipAddress: string) { 34 | return crypto.createHash('sha256').update(ipAddress).digest('hex'); 35 | } 36 | -------------------------------------------------------------------------------- /backend/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /backend/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "removeComments": true, 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "allowSyntheticDefaultImports": true, 8 | "sourceMap": true, 9 | "outDir": "./dist", 10 | "baseUrl": "./", 11 | "incremental": true, 12 | "strictNullChecks": true, 13 | "noImplicitAny": true, 14 | "strictBindCallApply": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "strictPropertyInitialization": false 18 | }, 19 | "extends": "@tsconfig/node20/tsconfig.json" 20 | } 21 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tseslint from 'typescript-eslint'; 3 | import globals from 'globals'; 4 | import path from 'node:path'; 5 | import reactHooks from 'eslint-plugin-react-hooks'; 6 | import reactRefresh from 'eslint-plugin-react-refresh'; 7 | import react from 'eslint-plugin-react'; 8 | import prettier from 'eslint-config-prettier'; 9 | 10 | const ignoreList = ['**/node_modules/**/*', '**/dist/**/*', '**/*.js', '**/*.mjs']; 11 | 12 | export default tseslint.config( 13 | { 14 | extends: [eslint.configs.recommended, ...tseslint.configs.recommended, prettier], 15 | ignores: ignoreList, 16 | languageOptions: { 17 | globals: globals.node, 18 | }, 19 | rules: { 20 | '@typescript-eslint/naming-convention': [ 21 | 'error', 22 | { 23 | selector: 'class', 24 | format: ['PascalCase'], 25 | }, 26 | { 27 | selector: 'interface', 28 | format: ['PascalCase'], 29 | }, 30 | { 31 | selector: 'typeAlias', 32 | format: ['PascalCase'], 33 | }, 34 | { 35 | selector: 'enum', 36 | format: ['PascalCase'], 37 | }, 38 | { 39 | selector: 'function', 40 | format: ['camelCase'], 41 | }, 42 | { 43 | selector: 'variable', 44 | format: ['camelCase', 'UPPER_CASE'], 45 | leadingUnderscore: 'allow', 46 | }, 47 | { 48 | selector: 'method', 49 | format: ['camelCase'], 50 | }, 51 | { 52 | selector: 'parameter', 53 | format: ['camelCase'], 54 | leadingUnderscore: 'allow', 55 | }, 56 | ], 57 | }, 58 | }, 59 | { 60 | files: ['frontend/**/*.{ts,tsx}'], 61 | ignores: ignoreList, 62 | languageOptions: { 63 | ecmaVersion: 2020, 64 | globals: globals.browser, 65 | }, 66 | settings: { 67 | react: { version: 'detect' }, 68 | }, 69 | extends: [react.configs.flat.recommended, react.configs.flat['jsx-runtime']], 70 | plugins: { 71 | 'react-hooks': reactHooks, 72 | 'react-refresh': reactRefresh, 73 | }, 74 | rules: { 75 | ...reactHooks.configs.recommended.rules, 76 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 77 | '@typescript-eslint/naming-convention': [ 78 | 'error', 79 | { 80 | selector: 'function', 81 | format: ['PascalCase', 'camelCase'], 82 | }, 83 | ], 84 | 'react/no-array-index-key': 'error', 85 | 'react/function-component-definition': ['error', { namedComponents: 'arrow-function' }], 86 | }, 87 | }, 88 | { 89 | files: ['backend/**/*.ts'], 90 | ignores: ignoreList, 91 | languageOptions: { 92 | parserOptions: { 93 | project: 'tsconfig.json', 94 | tsconfigRootDir: path.join(import.meta.dirname, 'backend'), 95 | sourceType: 'module', 96 | }, 97 | }, 98 | rules: { 99 | '@typescript-eslint/interface-name-prefix': 'off', 100 | '@typescript-eslint/explicit-function-return-type': 'off', 101 | '@typescript-eslint/explicit-module-boundary-types': 'off', 102 | '@typescript-eslint/no-explicit-any': 'off', 103 | }, 104 | } 105 | ); 106 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 12 | 13 | 14 | 18 | 19 | LearnDocker 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@tanstack/react-query": "^5.61.5", 14 | "@types/aos": "^3.0.7", 15 | "@xterm/addon-clipboard": "^0.1.0", 16 | "@xterm/addon-fit": "^0.10.0", 17 | "@xterm/xterm": "^5.5.0", 18 | "aos": "^2.3.4", 19 | "axios": "^1.7.7", 20 | "flowbite-react": "^0.10.2", 21 | "lucide-react": "^0.456.0", 22 | "react": "^18.3.1", 23 | "react-dom": "^18.3.1", 24 | "react-router-dom": "^6.28.0" 25 | }, 26 | "devDependencies": { 27 | "@eslint/js": "^9.13.0", 28 | "@types/react": "^18.3.12", 29 | "@types/react-dom": "^18.3.1", 30 | "@vitejs/plugin-react-swc": "^3.5.0", 31 | "autoprefixer": "^10.4.20", 32 | "eslint": "^9.13.0", 33 | "eslint-plugin-react-hooks": "^5.0.0", 34 | "eslint-plugin-react-refresh": "^0.4.14", 35 | "globals": "^15.11.0", 36 | "postcss": "^8.4.47", 37 | "tailwindcss": "^3.4.14", 38 | "typescript": "~5.6.2", 39 | "typescript-eslint": "^8.11.0", 40 | "vite": "^5.4.10" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import Header from './components/Header'; 2 | import Sidebar from './components/Sidebar'; 3 | import LandingPage from './components/staticpages/LandingPage'; 4 | import WhatIsDockerPage from './components/staticpages/DockerPage'; 5 | import WhatIsDockerImagePage from './components/staticpages/DockerImagePage'; 6 | import DockerContainerLifeCyclePage from './components/staticpages/DockerContainerLifeCyclePage'; 7 | import DockerContainerPage from './components/staticpages/DockerContainerPage'; 8 | import ErrorPage from './components/ErrorPage'; 9 | import { Routes, Route } from 'react-router-dom'; 10 | import AOS from 'aos'; 11 | import 'aos/dist/aos.css'; 12 | import { useEffect, useRef, useState } from 'react'; 13 | import { QuizPage } from './components/quiz/QuizPage'; 14 | import { Alert } from 'flowbite-react'; 15 | import { useAlert } from './hooks/useAlert'; 16 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 17 | import { useSidebar } from './hooks/useSidebar'; 18 | import { TimerModal } from './components/modals/TimerModal'; 19 | import { handleBeforeUnload } from './handlers/handler'; 20 | 21 | const queryClient = new QueryClient(); 22 | const App = () => { 23 | const { openAlert, message, showAlert } = useAlert(); 24 | const { sidebarStates, setSidebarStates } = useSidebar(); 25 | const [openTimerModal, setOpenTimerModal] = useState(false); 26 | const startButtonRef = useRef(null); 27 | const { dockerImageStates, dockerContainerStates } = sidebarStates; 28 | 29 | useEffect(() => { 30 | AOS.init({ 31 | duration: 500, 32 | }); 33 | 34 | window.addEventListener('beforeunload', handleBeforeUnload); 35 | 36 | return () => { 37 | window.removeEventListener('beforeunload', handleBeforeUnload); 38 | }; 39 | }, []); 40 | 41 | return ( 42 | 43 |
44 | 50 | {message} 51 | 52 |
53 |
54 | 60 |
61 | 62 | } 65 | /> 66 | 67 | } /> 68 | } 71 | /> 72 | } 75 | /> 76 | } 79 | /> 80 | 88 | } 89 | /> 90 | } /> 91 | } /> 92 | 93 |
94 |
95 |
96 | 97 |
98 | ); 99 | }; 100 | 101 | export default App; 102 | -------------------------------------------------------------------------------- /frontend/src/api/quiz.ts: -------------------------------------------------------------------------------- 1 | import { Terminal } from '@xterm/xterm'; 2 | import { Quiz, QuizResult } from '../types/quiz'; 3 | import { Visualization } from '../types/visualization'; 4 | import axios, { HttpStatusCode } from 'axios'; 5 | import { NavigateFunction } from 'react-router-dom'; 6 | import { CUSTOM_QUIZZES } from '../constant/quiz'; 7 | import LoadingTerminal from '../utils/LoadingTerminal'; 8 | import { HostStatus } from '../constant/hostStatus'; 9 | 10 | const handleErrorResponse = (error: unknown, navigate: NavigateFunction) => { 11 | if (axios.isAxiosError(error)) { 12 | if (error.response) { 13 | navigate(`/error/${error.response.status}`); 14 | } else if (error.request) { 15 | console.error('요청이 전송되었지만, 응답이 수신되지 않았습니다: ', error.request); 16 | } else { 17 | console.error( 18 | '오류가 발생한 요청을 설정하는 동안 문제가 발생했습니다: ', 19 | error.message 20 | ); 21 | } 22 | } else { 23 | console.error('unknown error'); 24 | } 25 | return null; 26 | }; 27 | 28 | export const requestQuizData = async (quizNumber: string, navigate: NavigateFunction) => { 29 | try { 30 | const response = await axios.get(`/api/quiz/${quizNumber}`); 31 | return response.data; 32 | } catch (error) { 33 | handleErrorResponse(error, navigate); 34 | throw error; 35 | } 36 | }; 37 | 38 | export const requestVisualizationData = async (navigate: NavigateFunction) => { 39 | try { 40 | const response = await axios.get(`/api/sandbox/elements`); 41 | return response.data; 42 | } catch (error) { 43 | return handleErrorResponse(error, navigate); 44 | } 45 | }; 46 | 47 | export const createHostContainer = async (navigate: NavigateFunction) => { 48 | try { 49 | const response = await axios.post(`/api/sandbox/start`); 50 | const { endDate } = response.data; 51 | return endDate; 52 | } catch (error) { 53 | return handleErrorResponse(error, navigate); 54 | } 55 | }; 56 | 57 | export const requestSubmitResult = async ( 58 | quizNumber: number, 59 | userAnswer: string, 60 | navigate: NavigateFunction 61 | ) => { 62 | if (CUSTOM_QUIZZES.includes(quizNumber)) { 63 | return requestCustomQuizResult(quizNumber, userAnswer, navigate); 64 | } else { 65 | return requestDockerQuizResult(quizNumber, navigate); 66 | } 67 | }; 68 | 69 | const requestCustomQuizResult = async ( 70 | quizNumber: number, 71 | userAnswer: string, 72 | navigate: NavigateFunction 73 | ) => { 74 | try { 75 | const response = await axios.get(`/api/quiz/${quizNumber}/submit`, { 76 | params: { 77 | userAnswer, 78 | }, 79 | }); 80 | return response.data; 81 | } catch (error) { 82 | return handleErrorResponse(error, navigate); 83 | } 84 | }; 85 | 86 | const requestDockerQuizResult = async (quizNumber: number, navigate: NavigateFunction) => { 87 | try { 88 | const response = await axios.get(`/api/quiz/${quizNumber}/submit`); 89 | return response.data; 90 | } catch (error) { 91 | return handleErrorResponse(error, navigate); 92 | } 93 | }; 94 | 95 | export const requestHostStatus = async (navigate: NavigateFunction) => { 96 | try { 97 | const response = await axios.get(`/api/sandbox/hostStatus`); 98 | return response.data; 99 | } catch (error) { 100 | return handleErrorResponse(error, navigate); 101 | } 102 | }; 103 | 104 | export const requestCommandResult = async ( 105 | command: string, 106 | term: Terminal, 107 | customErrorCallback: (term: Terminal, statusCode: number, errorMessage: string) => void 108 | ) => { 109 | const loadingTerminal = new LoadingTerminal(term); 110 | 111 | try { 112 | loadingTerminal.spinnerStart(); 113 | const response = await axios.post(`/api/sandbox/command`, { command }); 114 | loadingTerminal.spinnerStop(); 115 | return response.data; 116 | } catch (error) { 117 | loadingTerminal.spinnerStop(); 118 | if (axios.isAxiosError(error)) { 119 | const statusCode = error.response?.status || 500; 120 | customErrorCallback(term, statusCode, error.response?.data.message); 121 | } else { 122 | console.error('unknown error'); 123 | } 124 | return null; 125 | } 126 | }; 127 | 128 | export const requestQuizAccessability = async (quizId: number) => { 129 | try { 130 | await axios.get(`/api/quiz/${quizId}/access`); 131 | return HttpStatusCode.Ok; 132 | } catch (error) { 133 | if (axios.isAxiosError(error) && error.response) { 134 | if ( 135 | error.response.status === HttpStatusCode.Forbidden || 136 | error.response.status === HttpStatusCode.Unauthorized 137 | ) { 138 | return error.response.status; 139 | } 140 | } else { 141 | console.error('unknown error'); 142 | } 143 | return null; 144 | } 145 | }; 146 | 147 | export const requestReleaseSession = async (navigate?: NavigateFunction) => { 148 | try { 149 | await axios.delete(`/api/sandbox/release`); 150 | } catch (error) { 151 | if (navigate) return handleErrorResponse(error, navigate); 152 | } 153 | }; 154 | -------------------------------------------------------------------------------- /frontend/src/api/timer.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { ExpirationTime } from '../types/timer'; 3 | 4 | export const requestExpirationTime = async (): Promise => { 5 | try { 6 | const response = await axios.get(`/api/sandbox/endDate`); 7 | return response.data; 8 | } catch (error) { 9 | console.error(error); 10 | return { 11 | maxAge: new Date(0).toString(), 12 | }; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/assets/containerLifeCycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web34-LearnDocker/1d30e6b2c6729d9dfef494090afaba5d59509cce/frontend/src/assets/containerLifeCycle.png -------------------------------------------------------------------------------- /frontend/src/assets/docker-architecture.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web34-LearnDocker/1d30e6b2c6729d9dfef494090afaba5d59509cce/frontend/src/assets/docker-architecture.webp -------------------------------------------------------------------------------- /frontend/src/assets/docker-container.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web34-LearnDocker/1d30e6b2c6729d9dfef494090afaba5d59509cce/frontend/src/assets/docker-container.png -------------------------------------------------------------------------------- /frontend/src/assets/dropDown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web34-LearnDocker/1d30e6b2c6729d9dfef494090afaba5d59509cce/frontend/src/assets/favicon.ico -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web34-LearnDocker/1d30e6b2c6729d9dfef494090afaba5d59509cce/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/assets/quiz-page-red-box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web34-LearnDocker/1d30e6b2c6729d9dfef494090afaba5d59509cce/frontend/src/assets/quiz-page-red-box.png -------------------------------------------------------------------------------- /frontend/src/assets/visualization-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2024/web34-LearnDocker/1d30e6b2c6729d9dfef494090afaba5d59509cce/frontend/src/assets/visualization-demo.gif -------------------------------------------------------------------------------- /frontend/src/components/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from 'react-router-dom'; 2 | 3 | const ErrorPage = () => { 4 | const { id } = useParams(); 5 | const statusCode = id ? parseInt(id) : 404; 6 | 7 | const errorMessages: Record = { 8 | 403: 'Forbidden', 9 | 404: 'Page not found', 10 | 500: 'Internal server error', 11 | }; 12 | 13 | const message = 14 | statusCode && statusCode in errorMessages ? errorMessages[statusCode] : 'Unhandled error'; 15 | 16 | return ( 17 |
18 |

{statusCode}

19 |

{message}

20 |
21 | ); 22 | }; 23 | 24 | export default ErrorPage; 25 | -------------------------------------------------------------------------------- /frontend/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import mainLogo from '../assets/logo.png'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const Header = () => { 5 | return ( 6 |
7 | 8 | main logo 9 |

Learn Docker

10 | 11 |
12 | ); 13 | }; 14 | 15 | export default Header; 16 | -------------------------------------------------------------------------------- /frontend/src/components/PageInfoArea.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import PageType from './PageType'; 3 | import { Tooltip } from 'flowbite-react'; 4 | 5 | type PageInfoAreaProps = { 6 | pageType: string; 7 | path: string; 8 | title: string; 9 | }; 10 | const PageInfoArea = ({ pageType, path, title }: PageInfoAreaProps) => { 11 | const quizNum = Number(path.split('/').slice(-1)); 12 | const currentQuizNum = Number(sessionStorage.getItem('quiz')); 13 | 14 | return ( 15 |
16 | 17 | {pageType === 'quiz' && quizNum > currentQuizNum ? ( 18 | 26 | e.preventDefault()}> 27 | {title} 28 | 29 | 30 | ) : ( 31 | {title} 32 | )} 33 |
34 | ); 35 | }; 36 | export default PageInfoArea; 37 | -------------------------------------------------------------------------------- /frontend/src/components/PageType.tsx: -------------------------------------------------------------------------------- 1 | type PageTypeProps = { 2 | pageType: string; 3 | }; 4 | 5 | const PageType = ({ pageType }: PageTypeProps) => { 6 | return ( 7 | pageType === 'quiz' && ( 8 | 9 | {pageType} 10 | 11 | ) 12 | ); 13 | }; 14 | 15 | export default PageType; 16 | -------------------------------------------------------------------------------- /frontend/src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import dropDownImage from '../assets/dropDown.svg'; 3 | import StartButton from './StartButton'; 4 | import { SidebarSectionProps, SidebarState } from '../types/sidebar'; 5 | import TimerArea from './TimerArea'; 6 | import PageInfoArea from './PageInfoArea'; 7 | import { Check } from 'lucide-react'; 8 | 9 | const links = [ 10 | { title: 'Home', path: '/', pageType: 'education' }, 11 | { title: 'Docker란?', path: '/what-is-docker', pageType: 'education' }, 12 | ]; 13 | 14 | const SidebarSection = ({ title, links }: SidebarSectionProps) => { 15 | const [isOpen, setIsOpen] = useState(true); 16 | 17 | const toggleDropdown = () => { 18 | setIsOpen((prev) => !prev); 19 | }; 20 | 21 | return ( 22 | <> 23 |
24 |

{title}

25 | 32 |
33 | {isOpen && ( 34 |
    35 | {links.map((link) => { 36 | return ( 37 |
  • 38 |
    39 | 44 | {link.pageType === 'quiz' && link.solved && ( 45 | 46 | )} 47 |
    48 |
    49 |
  • 50 | ); 51 | })} 52 |
53 | )} 54 | 55 | ); 56 | }; 57 | 58 | type SidebarProps = { 59 | dockerImageStates: Array; 60 | dockerContainerStates: Array; 61 | setOpenTimerModal: React.Dispatch>; 62 | startButtonRef: React.MutableRefObject; 63 | }; 64 | 65 | const Sidebar = ({ 66 | setOpenTimerModal, 67 | startButtonRef, 68 | dockerImageStates, 69 | dockerContainerStates, 70 | }: SidebarProps) => { 71 | const [maxAge, setMaxAge] = useState(0); 72 | const endDate = window.sessionStorage.getItem('endDate'); 73 | useEffect(() => { 74 | setMaxAge(Number(endDate)); 75 | }, []); 76 | 77 | return ( 78 | 106 | ); 107 | }; 108 | 109 | export default Sidebar; 110 | -------------------------------------------------------------------------------- /frontend/src/components/StartButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { createHostContainer } from '../api/quiz'; 4 | import { LoaderCircle } from 'lucide-react'; 5 | type StartButtonProps = { 6 | setMaxAge: React.Dispatch>; 7 | startButtonRef: React.MutableRefObject; 8 | }; 9 | 10 | const StartButton = (props: StartButtonProps) => { 11 | const navigate = useNavigate(); 12 | const [loading, setLoading] = useState(false); 13 | const { setMaxAge, startButtonRef } = props; 14 | 15 | const handleButtonClick = async () => { 16 | setLoading(true); 17 | const endDate = await createHostContainer(navigate); 18 | const endDateTime = new Date(endDate).getTime(); 19 | if (endDate) { 20 | setLoading(false); 21 | setMaxAge(endDateTime); 22 | sessionStorage.setItem('quiz', '1'); 23 | window.sessionStorage.setItem('endDate', endDateTime.toString()); 24 | } 25 | }; 26 | 27 | return ( 28 |
29 | 44 |
45 | ); 46 | }; 47 | 48 | export default StartButton; 49 | -------------------------------------------------------------------------------- /frontend/src/components/StopButton.tsx: -------------------------------------------------------------------------------- 1 | import { requestReleaseSession } from '../api/quiz'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { handleBeforeUnload } from '../handlers/handler'; 4 | 5 | type StopButtonProps = { 6 | setMaxAge: React.Dispatch>; 7 | }; 8 | 9 | const StopButton = ({ setMaxAge }: StopButtonProps) => { 10 | const navigate = useNavigate(); 11 | 12 | const handleStopButtonClick = async () => { 13 | window.removeEventListener('beforeunload', handleBeforeUnload); 14 | await requestReleaseSession(navigate); 15 | window.sessionStorage.removeItem('endDate'); 16 | setMaxAge(0); 17 | sessionStorage.removeItem('quiz'); 18 | navigate('/'); 19 | window.location.reload(); 20 | }; 21 | 22 | return ( 23 | <> 24 | 31 | 32 | ); 33 | }; 34 | 35 | export default StopButton; 36 | -------------------------------------------------------------------------------- /frontend/src/components/Timer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { HOUR, MINUTE, SECOND } from '../constant/timer'; 3 | import { requestReleaseSession } from '../api/quiz'; 4 | 5 | const parseTime = (time: number) => { 6 | const hour = Math.floor(time / HOUR); 7 | time %= HOUR; 8 | const minute = Math.floor(time / MINUTE); 9 | time %= MINUTE; 10 | const second = Math.floor(time / 1000); 11 | 12 | return `${String(hour).padStart(2, '0')} : ${String(minute).padStart(2, '0')} : ${String(second).padStart(2, '0')}`; 13 | }; 14 | 15 | type TimerProps = { 16 | expirationTime: number; 17 | setMaxAge: React.Dispatch>; 18 | setOpenTimerModal: React.Dispatch>; 19 | }; 20 | 21 | export const Timer = (props: TimerProps) => { 22 | const { expirationTime, setMaxAge, setOpenTimerModal } = props; 23 | const [leftTime, setLeftTime] = useState(expirationTime - new Date().getTime()); 24 | useEffect(() => { 25 | const timer = setInterval(() => setLeftTime(leftTime - SECOND), SECOND); 26 | if (leftTime <= SECOND) { 27 | requestReleaseSession(); 28 | clearInterval(timer); 29 | setMaxAge(0); 30 | window.sessionStorage.removeItem('endDate'); 31 | } 32 | if (10 * MINUTE <= leftTime && leftTime <= 10 * MINUTE + SECOND) { 33 | setOpenTimerModal(true); 34 | } 35 | 36 | if (leftTime < 0) clearInterval(timer); 37 | 38 | return () => { 39 | clearInterval(timer); 40 | }; 41 | }, [leftTime]); 42 | 43 | return ( 44 | <> 45 | 46 | {parseTime(leftTime)} 47 | 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /frontend/src/components/TimerArea.tsx: -------------------------------------------------------------------------------- 1 | import { Timer } from './Timer'; 2 | import StopButton from './StopButton'; 3 | 4 | type TimerAreaProps = { 5 | expirationTime: number; 6 | setMaxAge: React.Dispatch>; 7 | setOpenTimerModal: React.Dispatch>; 8 | }; 9 | 10 | const TimerArea = ({ expirationTime, setMaxAge, setOpenTimerModal }: TimerAreaProps) => { 11 | return ( 12 |
13 | 14 | 남은 학습시간 15 | 16 | 21 | 22 |
23 | ); 24 | }; 25 | 26 | export default TimerArea; 27 | -------------------------------------------------------------------------------- /frontend/src/components/modals/QuizSubmitResultModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Modal } from 'flowbite-react'; 2 | import { CircleCheckBig, CircleAlert } from 'lucide-react'; 3 | import { SubmitStatus } from '../../types/quiz'; 4 | 5 | type ModalProps = { 6 | openModal: boolean; 7 | setOpenModal: (open: boolean) => void; 8 | submitResult: SubmitStatus; 9 | handleNextButtonClick: () => void; 10 | }; 11 | 12 | type ModalContentProps = { 13 | setOpenModal: (open: boolean) => void; 14 | handleNextButtonClick?: () => void; 15 | }; 16 | 17 | const CorrectModalContent = ({ setOpenModal, handleNextButtonClick }: ModalContentProps) => ( 18 |
19 | 20 |

정답입니다!

21 |
22 | 25 | 36 |
37 |
38 | ); 39 | 40 | const WrongModalContent = ({ setOpenModal }: ModalContentProps) => ( 41 |
42 | 43 |

오답입니다!

44 |
45 | 48 |
49 |
50 | ); 51 | 52 | export const QuizSubmitResultModal = ({ 53 | openModal, 54 | setOpenModal, 55 | submitResult, 56 | handleNextButtonClick, 57 | }: ModalProps) => { 58 | return ( 59 | <> 60 | setOpenModal(false)} popup> 61 | 62 | 63 | {submitResult === 'SUCCESS' ? ( 64 | 68 | ) : ( 69 | 70 | )} 71 | 72 | 73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /frontend/src/components/modals/TimerModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Modal } from 'flowbite-react'; 2 | import { Clock } from 'lucide-react'; 3 | 4 | type TimerMoalProps = { 5 | openTimerModal: boolean; 6 | setOpenTimerModal: React.Dispatch>; 7 | }; 8 | 9 | export const TimerModal = (props: TimerMoalProps) => { 10 | const { openTimerModal, setOpenTimerModal } = props; 11 | return ( 12 | <> 13 | setOpenTimerModal(false)} popup> 14 | 15 | 16 | 17 |
18 |

19 | 학습 종료 시간이 10분 남았습니다. 20 |

21 | 28 |
29 |
30 |
31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/src/components/popover/ContainerPopover.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from '../../types/visualization'; 2 | import { Tooltip } from 'flowbite-react'; 3 | 4 | type ContainerPopoverProps = { 5 | container: Container; 6 | }; 7 | 8 | const ContainerPopover = ({ container }: ContainerPopoverProps) => { 9 | const { id, name, image, status } = container; 10 | 11 | return ( 12 |
    13 |
  • 14 |
    ID
    15 | 16 |
    17 | {id} 18 |
    19 |
    20 |
  • 21 |
  • 22 |
    Name
    23 | 24 |
    25 | {name} 26 |
    27 |
    28 |
  • 29 |
  • 30 |
    Image
    31 | 32 |
    33 | {image} 34 |
    35 |
    36 |
  • 37 |
  • 38 |
    Status
    39 |
    40 | {status} 41 |
    42 |
  • 43 |
44 | ); 45 | }; 46 | 47 | export default ContainerPopover; 48 | -------------------------------------------------------------------------------- /frontend/src/components/popover/ImagePopover.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from '../../types/visualization'; 2 | import { Tooltip } from 'flowbite-react'; 3 | import { IMAGEID_PREFIX_INDEX } from '../../constant/visualization'; 4 | 5 | type ImagePopoverProps = { 6 | image: Image; 7 | }; 8 | 9 | const ImagePopover = ({ image }: ImagePopoverProps) => { 10 | const { id, name } = image; 11 | const filteredId = id.slice(IMAGEID_PREFIX_INDEX); 12 | 13 | return ( 14 |
    15 |
  • 16 |
    ID
    17 | 18 |
    19 | {filteredId} 20 |
    21 |
    22 |
  • 23 |
  • 24 |
    Name
    25 | 26 |
    27 | {name} 28 |
    29 |
    30 |
  • 31 |
32 | ); 33 | }; 34 | export default ImagePopover; 35 | -------------------------------------------------------------------------------- /frontend/src/components/quiz/QuizButtons.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { requestQuizAccessability, requestSubmitResult } from '../../api/quiz'; 4 | import { QuizSubmitResultModal } from '../modals/QuizSubmitResultModal'; 5 | import { SubmitStatus } from '../../types/quiz'; 6 | import { HttpStatusCode } from 'axios'; 7 | import { SidebarElementsProps } from '../../types/sidebar'; 8 | import { updateSidebarState } from '../../utils/sidebarUtils'; 9 | 10 | type QuizButtonsProps = { 11 | quizNumber: number; 12 | answer: string; 13 | showAlert: (message: string) => void; 14 | sidebarStates: SidebarElementsProps; 15 | setSidebarStates: React.Dispatch>; 16 | setUserLevel: React.Dispatch>; 17 | }; 18 | 19 | const QuizButtons = ({ 20 | quizNumber, 21 | answer, 22 | showAlert, 23 | sidebarStates, 24 | setSidebarStates, 25 | setUserLevel, 26 | }: QuizButtonsProps) => { 27 | const [submitResult, setSubmitResult] = useState('FAIL'); 28 | const [openModal, setOpenModal] = useState(false); 29 | const navigate = useNavigate(); 30 | 31 | const handleSubmitButtonClick = async () => { 32 | const { dockerImageStates, dockerContainerStates } = sidebarStates; 33 | const currentQuizNum = Number(sessionStorage.getItem('quiz')); 34 | const nextQuizNum = currentQuizNum + 1; 35 | 36 | const submitResponse = await requestSubmitResult(quizNumber, answer, navigate); 37 | if (submitResponse == null) { 38 | return; 39 | } 40 | 41 | setSubmitResult(submitResponse.quizResult); 42 | setOpenModal(true); 43 | 44 | if (submitResponse.quizResult === 'SUCCESS') { 45 | setUserLevel((prev) => { 46 | if (prev < quizNumber + 1) { 47 | return quizNumber + 1; 48 | } 49 | return prev; 50 | }); 51 | 52 | if (currentQuizNum === quizNumber && 1 <= quizNumber && quizNumber <= 3) { 53 | updateSidebarState(dockerImageStates, quizNumber); 54 | setSidebarStates({ ...sidebarStates }); 55 | sessionStorage.setItem('quiz', nextQuizNum.toString()); 56 | } 57 | if (currentQuizNum === quizNumber && 4 <= quizNumber && quizNumber <= 10) { 58 | updateSidebarState(dockerContainerStates, quizNumber); 59 | setSidebarStates({ ...sidebarStates }); 60 | sessionStorage.setItem('quiz', nextQuizNum.toString()); 61 | } 62 | } 63 | }; 64 | 65 | const handlePrevButtonClick = async () => { 66 | if (quizNumber === 1) { 67 | showAlert('처음 문제입니다'); 68 | return; 69 | } 70 | 71 | if (quizNumber === 4) { 72 | navigate('/what-is-container-lifecycle'); 73 | return; 74 | } 75 | 76 | navigate(`/quiz/${quizNumber - 1}`); 77 | }; 78 | 79 | const handleNextButtonClick = async () => { 80 | if (quizNumber > 9) { 81 | showAlert('마지막 문제입니다.'); 82 | return; 83 | } 84 | 85 | if (quizNumber === 3) { 86 | navigate('/what-is-docker-container'); 87 | return; 88 | } 89 | 90 | const accessStatus = await requestQuizAccessability(quizNumber + 1); 91 | if (!accessStatus) { 92 | return; 93 | } 94 | if (accessStatus === HttpStatusCode.Forbidden) { 95 | showAlert('아직 이동할 수 없습니다.'); 96 | return; 97 | } 98 | 99 | navigate(`/quiz/${quizNumber + 1}`); 100 | }; 101 | 102 | return ( 103 |
104 |
105 | 111 | 117 | 123 | 129 |
130 |
131 | ); 132 | }; 133 | 134 | export default QuizButtons; 135 | -------------------------------------------------------------------------------- /frontend/src/components/quiz/QuizDescription.tsx: -------------------------------------------------------------------------------- 1 | import { Accordion } from 'flowbite-react'; 2 | import { CircleHelp } from 'lucide-react'; 3 | 4 | type QuizDescriptionProps = { 5 | content: string; 6 | hint?: string; 7 | }; 8 | 9 | const QuizDescription = ({ content, hint = '' }: QuizDescriptionProps) => { 10 | return ( 11 |
12 |

문제

13 |

{content}

14 | {hint && ( 15 | 16 | 17 | 18 | 19 | Hint 20 | 21 | 22 | 23 |
27 | 28 | 29 | 30 | )} 31 |
32 | ); 33 | }; 34 | 35 | export default QuizDescription; 36 | -------------------------------------------------------------------------------- /frontend/src/components/quiz/QuizInputBox.tsx: -------------------------------------------------------------------------------- 1 | type QuizInputBoxProps = { 2 | answer: string; 3 | setAnswer: React.Dispatch>; 4 | }; 5 | 6 | const QuizInputBox = ({ answer, setAnswer }: QuizInputBoxProps) => { 7 | const handleChange = (e: React.ChangeEvent) => { 8 | setAnswer(e.target.value); 9 | }; 10 | return ( 11 |
12 | 19 |
20 | ); 21 | }; 22 | 23 | export default QuizInputBox; 24 | -------------------------------------------------------------------------------- /frontend/src/components/quiz/QuizNodes.tsx: -------------------------------------------------------------------------------- 1 | import { useQuizData } from '../../hooks/useQuizData'; 2 | import { CUSTOM_QUIZZES } from '../../constant/quiz'; 3 | import QuizDescription from './QuizDescription'; 4 | import { QuizSubmitArea } from './QuizSubmitArea'; 5 | import { SidebarElementsProps } from '../../types/sidebar'; 6 | 7 | type Props = { 8 | showAlert: (message: string) => void; 9 | quizId: string; 10 | sidebarStates: SidebarElementsProps; 11 | setSidebarStates: React.Dispatch>; 12 | setUserLevel: React.Dispatch>; 13 | }; 14 | 15 | export const QuizNodes = ({ 16 | showAlert, 17 | quizId, 18 | sidebarStates, 19 | setSidebarStates, 20 | setUserLevel, 21 | }: Props) => { 22 | const { title, content, hint, isPending: pending, isError: error } = useQuizData(quizId); 23 | const quizNumber = +quizId; 24 | const customQuiz = CUSTOM_QUIZZES.includes(quizNumber); 25 | 26 | if (pending) { 27 | return { 28 | head:

로딩 중...

, 29 | description: , 30 | submit: null, 31 | }; 32 | } 33 | 34 | if (error) { 35 | return { 36 | head:

오류 발생

, 37 | description: , 38 | submit: null, 39 | }; 40 | } 41 | 42 | return { 43 | head:

{title}

, 44 | description: , 45 | submit: ( 46 | 54 | ), 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /frontend/src/components/quiz/QuizPage.tsx: -------------------------------------------------------------------------------- 1 | import { QuizNodes } from './QuizNodes'; 2 | import { VisualizationNodes } from './VisualizationNodes'; 3 | import { QuizPageWrapper } from './QuizPageWrapper'; 4 | import { SidebarElementsProps } from '../../types/sidebar'; 5 | 6 | type QuizContentProps = { 7 | showAlert: (message: string) => void; 8 | quizId: string; 9 | sidebarStates: SidebarElementsProps; 10 | setSidebarStates: React.Dispatch>; 11 | setUserLevel: React.Dispatch>; 12 | }; 13 | 14 | type QuizPageProps = { 15 | showAlert: (message: string) => void; 16 | sidebarStates: SidebarElementsProps; 17 | setSidebarStates: React.Dispatch>; 18 | }; 19 | 20 | export const QuizContent = ({ 21 | showAlert, 22 | quizId, 23 | sidebarStates, 24 | setSidebarStates, 25 | setUserLevel, 26 | }: QuizContentProps) => { 27 | const quizNodes = QuizNodes({ 28 | showAlert, 29 | quizId, 30 | sidebarStates, 31 | setSidebarStates, 32 | setUserLevel, 33 | }); 34 | 35 | const visualNodes = VisualizationNodes({ showAlert }); 36 | 37 | return ( 38 | <> 39 | {quizNodes.head} 40 |
41 |
42 | {quizNodes.description} 43 | {visualNodes.visualization} 44 |
45 |
46 | {visualNodes.terminal} 47 | {quizNodes.submit} 48 |
49 |
50 | 51 | ); 52 | }; 53 | 54 | export const QuizPage = ({ showAlert, sidebarStates, setSidebarStates }: QuizPageProps) => { 55 | return ( 56 | 57 | {(quizId, setUserLevel) => ( 58 | 65 | )} 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /frontend/src/components/quiz/QuizPageWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { useParams, useNavigate } from 'react-router-dom'; 2 | import { useEffect, useState } from 'react'; 3 | import { requestQuizAccessability } from '../../api/quiz'; 4 | import { HttpStatusCode } from 'axios'; 5 | 6 | type QuizPageWrapperProps = { 7 | children: ( 8 | quizId: string, 9 | setUserLevel: React.Dispatch> 10 | ) => React.ReactNode; 11 | showAlert: (message: string) => void; 12 | }; 13 | 14 | export const QuizPageWrapper = ({ children, showAlert }: QuizPageWrapperProps) => { 15 | const { quizId } = useParams<{ quizId: string }>(); 16 | const quizNumber = Number(quizId); 17 | const [userLevel, setUserLevel] = useState(0); 18 | const navigate = useNavigate(); 19 | 20 | useEffect(() => { 21 | const validateQuiz = async () => { 22 | if (Number.isNaN(quizNumber) || quizNumber < 1 || quizNumber > 10) { 23 | navigate('/error/404'); 24 | return; 25 | } 26 | 27 | const accessStatus = await requestQuizAccessability(quizNumber); 28 | 29 | if (!accessStatus) { 30 | return; 31 | } 32 | if (accessStatus === HttpStatusCode.Forbidden) { 33 | showAlert('이전 퀴즈를 먼저 완료해주세요.'); 34 | navigate('/'); 35 | return; 36 | } 37 | if (accessStatus === HttpStatusCode.Unauthorized) { 38 | showAlert('학습 시작 버튼을 먼저 눌러주세요.'); 39 | navigate('/'); 40 | return; 41 | } 42 | 43 | if (userLevel < quizNumber) { 44 | setUserLevel(quizNumber); 45 | } 46 | }; 47 | 48 | validateQuiz(); 49 | }, [quizNumber, navigate, showAlert]); 50 | 51 | if (userLevel < quizNumber) { 52 | return null; 53 | } 54 | 55 | return
{children(quizId as string, setUserLevel)}
; 56 | }; 57 | -------------------------------------------------------------------------------- /frontend/src/components/quiz/QuizSubmitArea.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import QuizButtons from './QuizButtons'; 3 | import QuizInputBox from './QuizInputBox'; 4 | import { SidebarElementsProps } from '../../types/sidebar'; 5 | 6 | type QuizSubmitAreaProps = { 7 | quizNumber: number; 8 | showInput: boolean; 9 | showAlert: (message: string) => void; 10 | sidebarStates: SidebarElementsProps; 11 | setSidebarStates: React.Dispatch>; 12 | setUserLevel: React.Dispatch>; 13 | }; 14 | 15 | export const QuizSubmitArea = ({ 16 | quizNumber, 17 | showInput, 18 | showAlert, 19 | sidebarStates, 20 | setSidebarStates, 21 | setUserLevel, 22 | }: QuizSubmitAreaProps) => { 23 | const [answer, setAnswer] = useState(''); 24 | 25 | return ( 26 |
27 | {showInput && } 28 | 36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /frontend/src/components/quiz/VisualizationNodes.tsx: -------------------------------------------------------------------------------- 1 | import DockerVisualization from '../visualization/DockerVisualization'; 2 | import XTerminal from './XTerminal'; 3 | import useDockerVisualization from '../../hooks/useDockerVisualization'; 4 | import { useHostStatus } from '../../hooks/useHostStatus'; 5 | 6 | type VisualizationNodesProps = { 7 | showAlert: (alertMessage: string) => void; 8 | }; 9 | 10 | export const VisualizationNodes = ({ showAlert }: VisualizationNodesProps) => { 11 | const visualizationProps = useDockerVisualization(); 12 | const hostStatus = useHostStatus({ 13 | setInitVisualization: visualizationProps.setInitVisualization, 14 | }); 15 | 16 | return { 17 | visualization: , 18 | terminal: ( 19 | 24 | ), 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/components/quiz/XTerminal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { Terminal } from '@xterm/xterm'; 3 | import { useTerminal } from '../../hooks/useTerminal'; 4 | import { createTerminal } from '../../utils/terminalUtils'; 5 | import '@xterm/xterm/css/xterm.css'; 6 | import { HostStatus, HOST_STATUS } from '../../constant/hostStatus'; 7 | import LoadingTerminal from '../../utils/LoadingTerminal'; 8 | import { BrowserClipboardProvider } from '@xterm/addon-clipboard'; 9 | 10 | type XTerminalProps = { 11 | updateVisualizationData: (command: string) => Promise; 12 | hostStatus: HostStatus; 13 | showAlert: (alertMessage: string) => void; 14 | }; 15 | 16 | const XTerminal = ({ updateVisualizationData, hostStatus, showAlert }: XTerminalProps) => { 17 | const terminalRef = useRef(null); 18 | const terminalInstanceRef = useRef(null); 19 | const clipboardProviderRef = useRef(null); 20 | const { handleKeyInput } = useTerminal(updateVisualizationData, showAlert); 21 | const loadingRef = useRef(null); 22 | 23 | useEffect(() => { 24 | if (!terminalRef.current) return; 25 | 26 | const { terminal, clipboardProvider, fitAddon } = createTerminal(terminalRef.current); 27 | terminalInstanceRef.current = terminal; 28 | loadingRef.current = new LoadingTerminal(terminal); 29 | clipboardProviderRef.current = clipboardProvider; 30 | 31 | if (hostStatus === HOST_STATUS.STARTING) { 32 | loadingRef.current.hostSpinnerStart(); 33 | terminal.options.disableStdin = true; // 입력 비활성화 34 | } else { 35 | terminal.write('~$ '); 36 | } 37 | fitAddon.fit(); 38 | 39 | return () => terminal.dispose(); 40 | // eslint-disable-next-line react-hooks/exhaustive-deps 41 | }, []); 42 | 43 | useEffect(() => { 44 | if (!terminalInstanceRef.current || !clipboardProviderRef.current) return; 45 | 46 | if (!loadingRef.current) return; 47 | 48 | if (hostStatus === HOST_STATUS.READY) { 49 | loadingRef.current.hostSpinnerStop(); 50 | terminalInstanceRef.current.write('~$ '); 51 | terminalInstanceRef.current.options.disableStdin = false; // 입력 활성화 52 | terminalInstanceRef.current.onKey((event) => { 53 | if (hostStatus === HOST_STATUS.READY) { 54 | handleKeyInput( 55 | terminalInstanceRef.current as Terminal, 56 | event, 57 | clipboardProviderRef.current as BrowserClipboardProvider 58 | ); 59 | } 60 | }); 61 | terminalInstanceRef.current.focus(); 62 | } 63 | }, [hostStatus]); 64 | 65 | return ( 66 |
67 |
68 |
69 | ); 70 | }; 71 | 72 | export default XTerminal; 73 | -------------------------------------------------------------------------------- /frontend/src/components/staticpages/DockerContainerLifeCyclePage.tsx: -------------------------------------------------------------------------------- 1 | import lifeCylceImage from '../../assets/containerLifeCycle.png'; 2 | 3 | const DockerContainerLifeCyclePage = () => { 4 | return ( 5 |
6 |

Container 생명주기에 대해서 알아볼까요?

7 | container lifecycle 13 |
14 |
15 |

Container의 상태

16 |
17 |

18 | 컨테이너는{' '} 19 | 20 | 생성, 실행, 중지 등 다양한 상태를 가지며, Docker 명령어를 통해 21 | 상태를 전환 22 | 23 | 할 수 있습니다. 24 |

25 |
26 | 27 |

1. Created

28 |
29 |

30 | 도커 이미지로부터 도커 컨테이너가 생성된 상태이며 컨테이너가 실행된 31 | 상태는 아닙니다. 32 |

33 |
34 | 35 |

2. Running

36 |
37 |

38 | docker start 또는 docker run 명령어로 시작된, 실행 중인 컨테이너입니다. 39 |

40 |
41 | 42 |

3. Stopped

43 |
44 |

45 | 컨테이너의 실행이 중지된 상태로 컨테이너의 모든 프로세스가 종료되고, 46 | 컨테이너가 더 이상 실행되지 않습니다. 47 |

48 |
49 | 50 |

4. Paused

51 |
52 |

컨테이너 내부의 모든 프로세스가 일시 중단된 상태입니다.

53 |
54 | 55 |

5. Deleted

56 |
57 |

Docker 컨테이너가 삭제된 상태입니다.

58 |
59 |
60 |
61 |
62 | ); 63 | }; 64 | 65 | export default DockerContainerLifeCyclePage; 66 | -------------------------------------------------------------------------------- /frontend/src/components/staticpages/DockerImagePage.tsx: -------------------------------------------------------------------------------- 1 | const WhatIsDockerImagePage = () => { 2 | return ( 3 |
4 |
5 |

Docker Image란 무엇일까요?

6 |
7 |

8 | 이미지는{' '} 9 | 10 | 컨테이너 실행에 필요한 파일, 바이너리, 라이브러리, 설정 등을 모두 포함한 11 | 표준화된 패키지 12 | 13 | 입니다. 14 |

15 |

이미지에는 두 가지 중요한 원칙이 있습니다:

16 |
    17 |
  1. 18 |

    1. 이미지는 불변성을 가집니다

    19 |

    20 | 이미지가 생성되면 수정할 수 없습니다. 새 이미지를 만들거나 그 위에 21 | 변경 사항을 추가할 수 있을 뿐입니다. 22 |

    23 |
  2. 24 |
  3. 25 | 26 | 2. 이미지는 레이어로 구성됩니다 27 | 28 |

    29 | 각 레이어는 파일을 추가, 제거 또는 수정하는 일련의 파일 시스템 변경 30 | 사항을 나타냅니다. 31 |

    32 |
  4. 33 |
34 |

35 | 예를 들어, Python 앱을 구축하는 경우 Python 이미지를 기반으로 시작하여 36 | 애플리케이션의 종속성을 설치하거나 코드를 추가하는 새로운 레이어들을 만들 수 37 | 있습니다. 38 |

39 | 48 |
49 |
50 |
51 |

Docker Image명령어

52 |

Docker에서 Image를 조작하는 명령에 대해서 알아봅시다!

53 |
54 |

1. 이미지 가져오기

55 |
56 | 57 | docker pull [Image ID | Image Name] 58 | 59 |
60 |

61 | Docker Image를 Docker Registry로부터 가져와 local에 저장합니다. 62 |

63 |
64 |
65 |

2. 이미지 확인하기

66 |
67 | docker image ls 68 |
69 |

70 | Local에 저장된 Docker Image들의 목록을 한번에 확인할 수 있습니다. 71 |
72 | 73 | Aliases:{' '} 74 | 75 | docker images 76 | {' '} 77 | ,{' '} 78 | 79 | docker image list 80 | 81 | 82 |

83 |
84 | 85 | docker inspect [Image Name | Image ID] 86 | 87 |
88 |

89 | Local에 저장된 Docker Image 중 특정 Image의 정보를 확인할 수 있습니다. 90 |

91 |
92 |
93 |

3. 이미지 삭제하기

94 |
95 | 96 | docker image rm [Image ID | Image Name] 97 | 98 |
99 |

100 | Local에 저장된 특정 Docker Image를 삭제합니다. 101 |
102 | 103 | Aliases:{' '} 104 | 105 | docker rmi 106 | {' '} 107 | ,{' '} 108 | 109 | docker image remove 110 | 111 | 112 |

113 |
114 |
115 |
116 | ); 117 | }; 118 | 119 | export default WhatIsDockerImagePage; 120 | -------------------------------------------------------------------------------- /frontend/src/components/staticpages/DockerPage.tsx: -------------------------------------------------------------------------------- 1 | import architecture from '../../assets/docker-architecture.webp'; 2 | const WhatIsDockerPage = () => { 3 | return ( 4 | <> 5 |

6 | Docker란 무엇일까요? 7 |

8 |
12 |

13 | Docker는{' '} 14 | 15 | 애플리케이션을 개발, 제공 및 실행하기 위한 개방형 플랫폼 16 | {' '} 17 | 입니다. 18 |
19 | Docker 사용자는 개발한{' '} 20 | 21 | 애플리케이션을 인프라로부터 분리 22 | 23 | 하여 빠르게 제공할 수 있고, 애플리케이션을 관리하는 것과 동일한 방식으로 24 | 인프라를 관리할 수 있습니다. 25 |
26 | 코드 전달, 테스트 및 배포를 위한 Docker의 방법론을 활용하면 코드 작성 후 배포의 27 | 시간을 줄일 수 있습니다. 28 |

29 |
30 | 31 |

32 | Docker는 어떻게 사용할 수 있나요? 33 |

34 |
38 |

빠르고 일관된 애플리케이션을 제공하고 싶을때

39 |

40 |
41 | Docker는 개발자가 애플리케이션과 서비스를 실행하여 테스트할 수 있는{' '} 42 | 표준화된 환경을 로컬 컨테이너를 43 | 통해 제공합니다. 44 |
45 | 이러한 컨테이너는{' '} 46 | 지속적 통합(CI) 및{' '} 47 | 지속적 배포(CD) 워크플로우에 48 | 매우 적합합니다. 49 |

50 |
51 |
55 |

반응형 배포 및 확장을 하고 싶을때

56 |

57 |
58 | 도커의 컨테이너 기반 플랫폼은{' '} 59 | 다양한 환경에서 워크로드에 60 | 대응할 수 있도록 도와줍니다. 61 |
62 | 개발자가 운영하는 로컬 노트북, 데이터 센터의 물리적 또는 가상 시스템, 클라우드 63 | 서비스 등 여러 환경에서 도커 컨테이너를 실행할 수 있습니다. 64 |
65 | Docker는 가볍고 이식성이 뛰어나기에{' '} 66 | 워크로드를 동적으로 쉽게 관리할 67 | 수 있고, 애플리케이션과 서비스를{' '} 68 | 실시간에 가깝게 확장하거나 해체 69 | 할 수 있습니다. 70 |

71 |
72 |
76 |

77 | 동일한 하드웨어에서 더 많은 작업을 수행 및 실행하고 싶을때 78 |

79 |

80 |
81 | 도커는 가볍고 빠릅니다. 기존 82 | 하이퍼바이저 기반 가상 머신에 비해 실용적이고 비용 효율적인 대안을 제공하므로 더 83 | 많은 서버 용량을 사용하여 비즈니스 목표를 달성할 수 있습니다. 84 |
85 | 도커는 고밀도 환경과{' '} 86 | 더 적은 리소스로 더 많은 작업을 87 | 수행해야 하는 중소규모 배포 환경에 적합합니다. 88 |

89 |
90 | 91 |

92 | Docker의 Architecture 93 |

94 | 95 | 96 |

도커 데몬(Docker Daemon)

97 |
101 |

102 | 도커 데몬은 Docker API요청에 대해서 103 | listening합니다. 또한, Docker Objects에 해당하는 Container, Image, Network, 104 | Volume을 관리합니다. 105 |
106 | 또한, 도커 데몬은 다른 도커 데몬과 상호 작용할 수 있습니다. 107 |

108 |
109 |

도커 클라이언트(Docker Client)

110 |
114 |

115 | 도커 클라이언트는 많은 Docker 사용자가 116 | Docker와 상호 작용하는 주요 방법입니다. 117 |
118 | docker run과 같은 도커 명령어를 입력하면 도커 클라이언트는 Docker Daemon에게 119 | 해당 명령을 전달합니다. Docker Client는 Docker API를 활용하며, 하나 이상의 120 | Docker Daemon과 통신할 수 있습니다. 121 |

122 |
123 | 124 |

도커 레지스트리(Docker Registries)

125 |
129 |

130 | 도커 레지스트리는 Docker Image들을 저장하고 131 | 있습니다. 대표적으로 누구나 사용할 수 있는 Docker Hub가 있습니다. 132 |
133 | Docker는 기본적으로 Docker Hub를 통해 이미지를 탐색하도록 설정되어 있습니다. 134 | 또한, 자신만의 사설 레지스트리를 만들고 활용할 수도 있습니다. 135 |
136 | docker pull이나 docker run 명령의 경우 이미지를 가져와야 합니다. 필요한 이미지가 137 | 없는 경우 설정된 레지스트리로부터 해당 이미지를 가져옵니다. 138 |
139 | 자신이 만든 이미지는 docker push 명령을 통해 레지스트리에 등록할 수 있습니다. 140 |

141 |
142 | 143 |

도커 오브젝트(Docker Objects)

144 |
148 |

149 | Docker 사용자는 Docker Object들을 생성 및 사용할 수 있습니다.
150 | Docker Object에 포함되는 요소들은 다음과 같습니다. 151 |
152 |
153 | 1. Image 154 |
155 | 2. Container 156 |
157 | 3. Network 158 |
159 | 4. Volume 160 |
161 | 5. Plugins 162 |
163 | ... and more! 164 |

165 |
166 | 167 | ); 168 | }; 169 | 170 | export default WhatIsDockerPage; 171 | -------------------------------------------------------------------------------- /frontend/src/components/visualization/Arrow.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowProps } from '../../types/visualization'; 2 | 3 | export const Arrow = ({ 4 | icon: Icon, 5 | className, 6 | gridColumn, 7 | gridRow, 8 | isVisible = false, 9 | onAnimationEnd, 10 | }: ArrowProps) => { 11 | return ( 12 |
23 | 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/components/visualization/ArrowAnimation.tsx: -------------------------------------------------------------------------------- 1 | import { Arrow } from './Arrow'; 2 | import { MoveRight, MoveDownRight, MoveLeft, MoveUpRight, MoveUp } from 'lucide-react'; 3 | import { AnimationProps, DOCKER_OPERATIONS } from '../../types/visualization'; 4 | 5 | export const ArrowAnimation = ({ isVisible, onComplete, dockerOperation }: AnimationProps) => { 6 | switch (dockerOperation) { 7 | case DOCKER_OPERATIONS.IMAGE_PULL: 8 | return ( 9 | <> 10 | 17 | 24 | 31 | 39 | 40 | ); 41 | 42 | case DOCKER_OPERATIONS.IMAGE_DELETE: 43 | return ( 44 | <> 45 | 52 | 60 | 61 | ); 62 | 63 | case DOCKER_OPERATIONS.CONTAINER_CREATE: 64 | return ( 65 | <> 66 | 73 | 80 | 88 | 89 | ); 90 | case DOCKER_OPERATIONS.CONTAINER_DELETE: 91 | return ( 92 | <> 93 | 100 | 108 | 109 | ); 110 | case DOCKER_OPERATIONS.CONTAINER_RUN: 111 | return ( 112 | <> 113 | 120 | 127 | 134 | 141 | 148 | 155 | 163 | 164 | ); 165 | case DOCKER_OPERATIONS.CONTAINER_STATUS_CHANGED: 166 | return ( 167 | <> 168 | 175 | 183 | 184 | ); 185 | default: 186 | return null; 187 | } 188 | }; 189 | -------------------------------------------------------------------------------- /frontend/src/components/visualization/BaseNode.tsx: -------------------------------------------------------------------------------- 1 | import { NodeProps } from '../../types/visualization'; 2 | 3 | export const BaseNode = ({ label, icon: Icon, gridRow, gridColumn }: NodeProps) => { 4 | return ( 5 |
12 | {label} 13 | 14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/components/visualization/ContainerNode.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ContainerNodeProps, 3 | DOCKER_CONTAINER_STATUS, 4 | Image, 5 | Container, 6 | } from '../../types/visualization'; 7 | import { Popover } from 'flowbite-react'; 8 | import ContainerPopover from '../popover/ContainerPopover'; 9 | import ImagePopover from '../popover/ImagePopover'; 10 | 11 | const STATUS_COLORS = { 12 | [DOCKER_CONTAINER_STATUS.EXITED]: 'bg-Stopped-Status-Color', 13 | [DOCKER_CONTAINER_STATUS.RESTARTING]: 'bg-Restarting-Status-Color', 14 | [DOCKER_CONTAINER_STATUS.RUNNING]: 'bg-Running-Status-Color', 15 | [DOCKER_CONTAINER_STATUS.CREATED]: 'bg-Stopped-Status-Color', 16 | [DOCKER_CONTAINER_STATUS.PAUSED]: 'bg-Stopped-Status-Color', 17 | [DOCKER_CONTAINER_STATUS.STOPPED]: 'bg-Stopped-Status-Color', 18 | [DOCKER_CONTAINER_STATUS.DEAD]: 'bg-Stopped-Status-Color', 19 | }; 20 | 21 | const isContainer = (element: Image | Container): element is Container => { 22 | return (element as Container).status !== undefined; 23 | }; 24 | 25 | const ContainerNode = ({ 26 | label, 27 | icon: Icon, 28 | gridRow, 29 | gridColumn, 30 | containerData, 31 | }: ContainerNodeProps) => { 32 | return ( 33 |
40 |
41 | {label} 42 | 43 |
44 |
45 | {containerData?.map((element) => ( 46 | 51 | ) : ( 52 | 53 | ) 54 | } 55 | key={element.id} 56 | > 57 |
63 | {isContainer(element) ? ( 64 | <> 65 | 68 | 71 | 72 |
{element.name}
73 | 74 | ) : ( 75 |
{element.name}
76 | )} 77 |
78 |
79 | ))} 80 |
81 |
82 | ); 83 | }; 84 | 85 | export default ContainerNode; 86 | -------------------------------------------------------------------------------- /frontend/src/components/visualization/DockerVisualization.tsx: -------------------------------------------------------------------------------- 1 | import { Laptop, Server, Container, Database, Cloud } from 'lucide-react'; 2 | import { BaseNode } from './BaseNode'; 3 | import ContainerNode from './ContainerNode'; 4 | import { DockerVisualizationProps } from '../../types/visualization'; 5 | import { ArrowAnimation } from './ArrowAnimation'; 6 | 7 | const DockerVisualization = ({ 8 | animationState, 9 | elements, 10 | dockerOperation, 11 | onAnimationComplete, 12 | }: DockerVisualizationProps) => { 13 | const { images, containers } = elements; 14 | const initBaseNodes = [ 15 | { 16 | label: 'client', 17 | icon: Laptop, 18 | gridColumn: 1, 19 | gridRow: 3, 20 | }, 21 | { 22 | label: 'docker engine', 23 | icon: Server, 24 | gridColumn: 3, 25 | gridRow: 3, 26 | }, 27 | 28 | { 29 | label: 'registry', 30 | icon: Cloud, 31 | gridColumn: 7, 32 | gridRow: 4, 33 | }, 34 | ]; 35 | 36 | return ( 37 |
44 | {initBaseNodes.map(({ label, icon, gridRow, gridColumn }) => ( 45 | 52 | ))} 53 | 61 | 69 | 70 | 76 |
77 | ); 78 | }; 79 | 80 | export default DockerVisualization; 81 | -------------------------------------------------------------------------------- /frontend/src/constant/hostStatus.ts: -------------------------------------------------------------------------------- 1 | export const HOST_STATUS = { 2 | STARTING: 'STARTING', 3 | READY: 'READY', 4 | } as const; 5 | 6 | export type HostStatus = (typeof HOST_STATUS)[keyof typeof HOST_STATUS]; 7 | -------------------------------------------------------------------------------- /frontend/src/constant/quiz.ts: -------------------------------------------------------------------------------- 1 | export const CUSTOM_QUIZZES: readonly number[] = [2, 5, 7, 8]; 2 | -------------------------------------------------------------------------------- /frontend/src/constant/sidebarStatus.ts: -------------------------------------------------------------------------------- 1 | import { SidebarState } from '../types/sidebar'; 2 | 3 | export const dockerImageInitStates: SidebarState[] = [ 4 | { 5 | title: 'Docker image란?', 6 | path: '/what-is-docker-image', 7 | pageType: 'education', 8 | }, 9 | { title: 'image 가져오기', path: '/quiz/1', pageType: 'quiz', solved: false }, 10 | { title: 'image 목록 확인하기', path: '/quiz/2', pageType: 'quiz', solved: false }, 11 | { title: 'image 삭제하기', path: '/quiz/3', pageType: 'quiz', solved: false }, 12 | ]; 13 | 14 | export const dockerContainerInitStates: SidebarState[] = [ 15 | { 16 | title: 'Docker Container란?', 17 | path: '/what-is-docker-container', 18 | pageType: 'education', 19 | }, 20 | { 21 | title: 'Container의 생명주기', 22 | path: '/what-is-container-lifecycle', 23 | pageType: 'education', 24 | }, 25 | { title: 'Container 생성하기', path: '/quiz/4', pageType: 'quiz', solved: false }, 26 | { title: 'Container 실행하기', path: '/quiz/5', pageType: 'quiz', solved: false }, 27 | { title: 'Container 생성 및 실행하기', path: '/quiz/6', pageType: 'quiz', solved: false }, 28 | { title: 'Container 로그 확인하기', path: '/quiz/7', pageType: 'quiz', solved: false }, 29 | { title: 'Container 목록 확인하기', path: '/quiz/8', pageType: 'quiz', solved: false }, 30 | { title: 'Container 중지하기', path: '/quiz/9', pageType: 'quiz', solved: false }, 31 | { title: 'Container 삭제하기', path: '/quiz/10', pageType: 'quiz', solved: false }, 32 | ]; 33 | -------------------------------------------------------------------------------- /frontend/src/constant/timer.ts: -------------------------------------------------------------------------------- 1 | export const SECOND = 1000; 2 | export const MINUTE = 60 * SECOND; 3 | export const HOUR = 60 * MINUTE; 4 | export const MAX_TIME = 4 * HOUR; 5 | -------------------------------------------------------------------------------- /frontend/src/constant/visualization.ts: -------------------------------------------------------------------------------- 1 | export const COLORS = ['#000000', '#FFC107', '#4CAF50', '#2196F3', '#673AB7', '#E91E63']; 2 | export const STATE_CHANGE_COMMAND_REGEX = 3 | /^docker\s+(run|create|start|stop|pull|rmi|rm|restart|pause|unpause|rename|attach|tag|build|load|commit|kill)(\s|$)/; 4 | export const IMAGEID_PREFIX_INDEX = 7; 5 | -------------------------------------------------------------------------------- /frontend/src/constant/xterm.ts: -------------------------------------------------------------------------------- 1 | export const ENTER_KEY = '\r'; 2 | export const BACKSPACE_KEY = '\x7f'; 3 | -------------------------------------------------------------------------------- /frontend/src/handlers/handler.ts: -------------------------------------------------------------------------------- 1 | export const handleBeforeUnload = (e: BeforeUnloadEvent) => { 2 | e.preventDefault(); 3 | return ''; 4 | }; 5 | -------------------------------------------------------------------------------- /frontend/src/hooks/useAlert.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export const useAlert = () => { 4 | const [openAlert, setOpenAlert] = useState(false); 5 | const [message, setMessage] = useState(''); 6 | 7 | const showAlert = (alertMessage: string) => { 8 | setMessage(alertMessage); 9 | setOpenAlert(true); 10 | setTimeout(() => { 11 | setOpenAlert(false); 12 | }, 3000); 13 | } 14 | return { openAlert, showAlert, message }; 15 | } -------------------------------------------------------------------------------- /frontend/src/hooks/useDockerVisualization.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react'; 2 | import { requestVisualizationData } from '../api/quiz'; 3 | import { DOCKER_OPERATIONS, AnimationState, DockerOperation } from '../types/visualization'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { Visualization } from '../types/visualization'; 6 | import { 7 | setColorToElements, 8 | updateImageColors, 9 | updateContainerColors, 10 | getDockerOperation, 11 | } from '../utils/visualizationUtils'; 12 | import { STATE_CHANGE_COMMAND_REGEX } from '../constant/visualization'; 13 | 14 | const useDockerVisualization = () => { 15 | const navigate = useNavigate(); 16 | const [elements, setElements] = useState({ images: [], containers: [] }); 17 | const [dockerOperation, setDockerOperation] = useState(); 18 | const [animation, setAnimation] = useState({ 19 | isVisible: false, 20 | key: 0, 21 | }); 22 | const elementsRef = useRef({ images: [], containers: [] }); 23 | 24 | const handleImageStateLengthChange = ( 25 | prevElements: Visualization, 26 | newElements: Visualization, 27 | dockerOperation: DockerOperation 28 | ) => { 29 | const updatedImages = updateImageColors(prevElements, newElements); 30 | 31 | elementsRef.current.images = [...updatedImages]; 32 | setDockerOperation(dockerOperation); 33 | setAnimation((prev) => ({ 34 | isVisible: true, 35 | key: prev.key + 1, 36 | })); 37 | }; 38 | 39 | const handleContainerStateLengthChange = ( 40 | newElements: Visualization, 41 | dockerOperation: DockerOperation 42 | ) => { 43 | const prevImages = elementsRef.current; 44 | const updatedContainers = updateContainerColors(prevImages, newElements); 45 | 46 | elementsRef.current.containers = [...updatedContainers]; 47 | setDockerOperation(dockerOperation); 48 | setAnimation((prev) => ({ 49 | isVisible: true, 50 | key: prev.key + 1, 51 | })); 52 | }; 53 | 54 | const handleElementsStateLengthChange = (newElements: Visualization) => { 55 | const prevElements = elementsRef.current; 56 | const { initImages, initContainers } = setColorToElements(prevElements, newElements); 57 | 58 | elementsRef.current.images = [...initImages]; 59 | elementsRef.current.containers = [...initContainers]; 60 | setDockerOperation(DOCKER_OPERATIONS.CONTAINER_RUN); 61 | setAnimation((prev) => ({ 62 | isVisible: true, 63 | key: prev.key + 1, 64 | })); 65 | }; 66 | 67 | const handleContainerStateChange = (newElements: Visualization, command: string) => { 68 | const prevImages = elementsRef.current; 69 | const updatedContainers = updateContainerColors(prevImages, newElements); 70 | const elements = { images: prevImages.images, containers: updatedContainers }; 71 | 72 | elementsRef.current.containers = [...updatedContainers]; 73 | setDockerOperation(DOCKER_OPERATIONS.CONTAINER_STATUS_CHANGED); 74 | 75 | if (command.match(STATE_CHANGE_COMMAND_REGEX)) { 76 | setAnimation((prev) => ({ 77 | isVisible: true, 78 | key: prev.key + 1, 79 | })); 80 | } else { 81 | setElements(elements); 82 | } 83 | }; 84 | 85 | const handleTerminalEnterCallback = (data: Visualization, command: string) => { 86 | const prevElements = elementsRef.current; 87 | const newElements = data; 88 | const operation = getDockerOperation(prevElements, newElements); 89 | 90 | switch (operation) { 91 | case DOCKER_OPERATIONS.IMAGE_PULL: 92 | handleImageStateLengthChange(prevElements, newElements, operation); 93 | break; 94 | case DOCKER_OPERATIONS.IMAGE_DELETE: 95 | handleImageStateLengthChange(prevElements, newElements, operation); 96 | break; 97 | case DOCKER_OPERATIONS.CONTAINER_CREATE: 98 | handleContainerStateLengthChange(newElements, operation); 99 | break; 100 | case DOCKER_OPERATIONS.CONTAINER_RUN: 101 | handleElementsStateLengthChange(newElements); 102 | break; 103 | case DOCKER_OPERATIONS.CONTAINER_DELETE: 104 | handleContainerStateLengthChange(newElements, operation); 105 | break; 106 | case DOCKER_OPERATIONS.CONTAINER_STATUS_CHANGED: 107 | handleContainerStateChange(newElements, command); 108 | break; 109 | } 110 | }; 111 | 112 | const updateVisualizationData = async (command: string) => { 113 | const data = await requestVisualizationData(navigate); 114 | if (!data) return; 115 | handleTerminalEnterCallback(data, command); 116 | }; 117 | 118 | const setInitVisualization = async () => { 119 | const newElements = await requestVisualizationData(navigate); 120 | if (!newElements) return; 121 | 122 | const prevElements = elementsRef.current; 123 | const { initImages, initContainers } = setColorToElements(prevElements, newElements); 124 | 125 | elementsRef.current.images = [...initImages]; 126 | elementsRef.current.containers = [...initContainers]; 127 | setElements({ images: initImages, containers: initContainers }); 128 | }; 129 | 130 | const handleAnimationComplete = () => { 131 | const { images, containers } = elementsRef.current; 132 | 133 | setElements({ images, containers }); 134 | setAnimation((prev) => ({ 135 | isVisible: false, 136 | key: prev.key, 137 | })); 138 | }; 139 | 140 | return { 141 | elements, 142 | animationState: animation, 143 | dockerOperation, 144 | onAnimationComplete: handleAnimationComplete, 145 | updateVisualizationData, 146 | handleTerminalEnterCallback, 147 | setInitVisualization, 148 | }; 149 | }; 150 | 151 | export default useDockerVisualization; 152 | -------------------------------------------------------------------------------- /frontend/src/hooks/useHostStatus.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { requestHostStatus } from '../api/quiz'; 4 | import { HostStatus, HOST_STATUS } from '../constant/hostStatus'; 5 | 6 | type UseHostStatusProps = { 7 | setInitVisualization: () => Promise; 8 | }; 9 | 10 | export const useHostStatus = ({ setInitVisualization }: UseHostStatusProps) => { 11 | const navigate = useNavigate(); 12 | const [hostStatus, setHostStatus] = useState(HOST_STATUS.STARTING); 13 | const pollingRef = useRef(true); 14 | const pollingIntervalRef = useRef(null); 15 | 16 | const checkHostStatus = async () => { 17 | const response = await requestHostStatus(navigate); 18 | 19 | if (!response) { 20 | return; 21 | } 22 | 23 | setHostStatus(response); 24 | 25 | if (response === HOST_STATUS.READY) { 26 | pollingRef.current = false; 27 | if (pollingIntervalRef.current) { 28 | clearInterval(pollingIntervalRef.current); 29 | } 30 | } 31 | }; 32 | 33 | useEffect(() => { 34 | const initializeHostStatus = async () => { 35 | await checkHostStatus(); 36 | 37 | if (pollingRef.current) { 38 | pollingIntervalRef.current = setInterval(checkHostStatus, 1000); 39 | } else { 40 | setInitVisualization(); 41 | } 42 | }; 43 | 44 | initializeHostStatus(); 45 | 46 | return () => { 47 | if (pollingIntervalRef.current) { 48 | clearInterval(pollingIntervalRef.current); 49 | pollingIntervalRef.current = null; 50 | } 51 | }; 52 | 53 | // eslint-disable-next-line react-hooks/exhaustive-deps 54 | }, []); 55 | 56 | return hostStatus; 57 | }; 58 | -------------------------------------------------------------------------------- /frontend/src/hooks/useQuizData.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { requestQuizData } from '../api/quiz'; 4 | 5 | export const useQuizData = (quizId: string) => { 6 | const navigate = useNavigate(); 7 | 8 | const { 9 | data: quizData, 10 | isPending, 11 | isError, 12 | } = useQuery({ 13 | queryKey: ['quiz', quizId], 14 | queryFn: () => requestQuizData(quizId, navigate), 15 | staleTime: Infinity, // 데이터를 항상 fresh하게 유지 16 | gcTime: Infinity, // 캐시를 영구적으로 유지 17 | }); 18 | 19 | return { 20 | id: quizData?.id ?? 0, 21 | title: quizData?.title ?? '', 22 | content: quizData?.content ?? '', 23 | hint: quizData?.hint ?? '', 24 | isPending, 25 | isError, 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /frontend/src/hooks/useSidebar.ts: -------------------------------------------------------------------------------- 1 | import { dockerImageInitStates, dockerContainerInitStates } from '../constant/sidebarStatus'; 2 | import { useState } from 'react'; 3 | import { SidebarElementsProps, SidebarState } from '../types/sidebar'; 4 | import { updateSidebarState } from '../utils/sidebarUtils'; 5 | 6 | export const useSidebar = () => { 7 | const imageStates = [...dockerImageInitStates]; 8 | const containerStates = [...dockerContainerInitStates]; 9 | const getSidebarInitState = ( 10 | imageStates: SidebarState[], 11 | containerStates: SidebarState[], 12 | quizNum: number 13 | ) => { 14 | updateSidebarState(imageStates, quizNum - 1); 15 | 16 | if (4 <= quizNum) { 17 | updateSidebarState(containerStates, quizNum - 1); 18 | } 19 | return { 20 | dockerImageStates: imageStates, 21 | dockerContainerStates: containerStates, 22 | }; 23 | }; 24 | const { dockerImageStates, dockerContainerStates } = getSidebarInitState( 25 | imageStates, 26 | containerStates, 27 | Number(sessionStorage.getItem('quiz')) 28 | ) as SidebarElementsProps; 29 | const [sidebarStates, setSidebarStates] = useState({ 30 | dockerImageStates, 31 | dockerContainerStates, 32 | }); 33 | return { sidebarStates, setSidebarStates }; 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/hooks/useTerminal.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { Terminal } from '@xterm/xterm'; 3 | import { 4 | handleEnter, 5 | handleBackspace, 6 | handleDefaultKey, 7 | isPrintableKey, 8 | } from '../utils/terminalUtils'; 9 | import { ENTER_KEY, BACKSPACE_KEY } from '../constant/xterm'; 10 | import { HttpStatusCode } from 'axios'; 11 | import { BrowserClipboardProvider, ClipboardSelectionType } from '@xterm/addon-clipboard'; 12 | 13 | export function useTerminal( 14 | updateVisualizationData: (command: string) => Promise, 15 | showAlert: (alertMessage: string) => void 16 | ) { 17 | const currentLineRef = useRef(''); 18 | const blockingRef = useRef(false); 19 | const tooManyRequestRef = useRef(false); 20 | 21 | const handleCommandError = (term: Terminal, statusCode: number, errorMessage: string) => { 22 | if (!term) return; 23 | if (statusCode === HttpStatusCode.TooManyRequests) { 24 | showAlert('잠시후 다시 시도해주세요'); 25 | 26 | tooManyRequestRef.current = true; 27 | setTimeout(() => { 28 | tooManyRequestRef.current = false; 29 | }, 1000); 30 | 31 | return; 32 | } 33 | 34 | const message = errorMessage || '허용되지 않은 명령어 입니다.'; 35 | term.write(`\x1b[91m${message}\x1b[0m\r\n`); 36 | }; 37 | 38 | const handleKeyInput = async ( 39 | term: Terminal, 40 | event: { key: string; domEvent: KeyboardEvent }, 41 | clipboardProvider: BrowserClipboardProvider 42 | ) => { 43 | if (blockingRef.current || tooManyRequestRef.current) return; 44 | 45 | if ((event.domEvent.metaKey || event.domEvent.ctrlKey) && event.domEvent.key === 'c') { 46 | const selection = term.getSelection(); 47 | if (selection) { 48 | await clipboardProvider.writeText('c' as ClipboardSelectionType, selection); 49 | } 50 | return; 51 | } 52 | 53 | if ((event.domEvent.metaKey || event.domEvent.ctrlKey) && event.domEvent.key === 'v') { 54 | try { 55 | const text = await clipboardProvider.readText('c' as ClipboardSelectionType); 56 | term.write(text); 57 | currentLineRef.current += text; 58 | } catch (err) { 59 | console.error('Failed to paste:', err); 60 | } 61 | return; 62 | } 63 | 64 | switch (event.key) { 65 | case ENTER_KEY: { 66 | blockingRef.current = true; 67 | await handleEnter( 68 | term, 69 | currentLineRef.current.trim(), 70 | handleCommandError, 71 | updateVisualizationData 72 | ); 73 | currentLineRef.current = ''; 74 | blockingRef.current = false; 75 | break; 76 | } 77 | 78 | case BACKSPACE_KEY: { 79 | currentLineRef.current = handleBackspace(term, currentLineRef.current); 80 | break; 81 | } 82 | 83 | default: { 84 | if (!isPrintableKey(event.key)) return; 85 | currentLineRef.current = handleDefaultKey(term, event.key, currentLineRef.current); 86 | break; 87 | } 88 | } 89 | }; 90 | 91 | return { handleKeyInput }; 92 | } 93 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .custom-terminal { 6 | scrollbar-width: thin; 7 | scrollbar-color: #666 #1e1e1e; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import './index.css'; 3 | import App from './App.tsx'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /frontend/src/types/quiz.ts: -------------------------------------------------------------------------------- 1 | export type Quiz = { 2 | id: number; 3 | title: string; 4 | content: string | undefined; 5 | hint: string | undefined; 6 | }; 7 | 8 | export type SubmitStatus = 'SUCCESS' | 'FAIL'; 9 | 10 | export type QuizResult = { 11 | quizResult: SubmitStatus; 12 | }; 13 | 14 | export type QuizTextAreaProps = { 15 | updateVisualizationData: () => Promise; 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/types/sidebar.ts: -------------------------------------------------------------------------------- 1 | export type SidebarSectionProps = { 2 | title: string; 3 | links: SidebarState[]; 4 | }; 5 | 6 | export type SidebarElementsProps = { 7 | dockerImageStates: Array; 8 | dockerContainerStates: Array; 9 | }; 10 | 11 | export type SidebarState = { 12 | title: string; 13 | path: string; 14 | pageType: string; 15 | solved?: boolean; 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/types/timer.ts: -------------------------------------------------------------------------------- 1 | export type ExpirationTime = { 2 | endDate: string; 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/src/types/visualization.ts: -------------------------------------------------------------------------------- 1 | import { LucideIcon } from 'lucide-react'; 2 | 3 | export type Visualization = { 4 | containers: Container[]; 5 | images: Image[]; 6 | }; 7 | 8 | export type Image = { 9 | id: string; 10 | name: string; 11 | color?: string; 12 | }; 13 | 14 | export type Container = { 15 | id: string; 16 | name: string; 17 | image: string; 18 | status: string; 19 | color?: string; 20 | }; 21 | 22 | export type AnimationState = { 23 | isVisible: boolean; 24 | key: number; 25 | }; 26 | 27 | export type NodeProps = { 28 | label: string; 29 | icon: LucideIcon; 30 | gridColumn: number; 31 | gridRow: number; 32 | }; 33 | 34 | export type ContainerNodeProps = { 35 | label: string; 36 | icon: LucideIcon; 37 | gridColumn: number; 38 | gridRow: number; 39 | containerData: Image[] | Container[] | undefined; 40 | }; 41 | 42 | export type DockerVisualizationProps = { 43 | animationState: AnimationState; 44 | elements: Visualization; 45 | dockerOperation: DockerOperation | undefined; 46 | onAnimationComplete: () => void; 47 | }; 48 | 49 | export type ArrowProps = { 50 | icon: LucideIcon; 51 | className?: string; 52 | gridColumn: number; 53 | gridRow: number; 54 | isVisible?: boolean; 55 | onAnimationEnd?: () => void; 56 | }; 57 | 58 | export type AnimationProps = { 59 | isVisible: boolean; 60 | onComplete: () => void; 61 | dockerOperation: DockerOperation | undefined; 62 | }; 63 | 64 | export const DOCKER_OPERATIONS = { 65 | IMAGE_PULL: 'IMAGE_PULL', 66 | IMAGE_DELETE: 'IMEAGE_DELETE', 67 | CONTAINER_CREATE: 'CONTAINER_CREATE', 68 | CONTAINER_DELETE: 'CONTAINER_DELETE', 69 | CONTAINER_RUN: 'CONTAINER_RUN', 70 | CONTAINER_STATUS_CHANGED: 'CONTAINER_STATUS_CHANGED', 71 | } as const; 72 | 73 | export const DOCKER_CONTAINER_STATUS = { 74 | EXITED: 'exited', 75 | RUNNING: 'running', 76 | CREATED: 'created', 77 | PAUSED: 'paused', 78 | STOPPED: 'stopped', 79 | DEAD: 'dead', 80 | RESTARTING: 'restarting', 81 | }; 82 | 83 | export type DockerOperation = (typeof DOCKER_OPERATIONS)[keyof typeof DOCKER_OPERATIONS]; 84 | -------------------------------------------------------------------------------- /frontend/src/utils/LoadingTerminal.ts: -------------------------------------------------------------------------------- 1 | import { Terminal } from '@xterm/xterm'; 2 | 3 | export default class LoadingTerminal { 4 | private term: Terminal; 5 | private spinnerFrames: string[]; 6 | private hostSpinnerFrames: string[]; 7 | private currentFrame: number; 8 | private loadingInterval: number; 9 | private loadingTimeout: number; 10 | 11 | constructor(term: Terminal) { 12 | this.term = term; 13 | this.spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; 14 | this.hostSpinnerFrames = [ 15 | '🐳 ∘°◦ ', 16 | ' 🐳 ∘°◦ ', 17 | ' 🐳 ∘°◦ ', 18 | ' 🐳 ∘°◦ ', 19 | ' 🐳 ∘°◦ ', 20 | ' 🐳 ∘°◦ ', 21 | ' 🐳 ∘°◦ ', 22 | ' 🐳 ∘°◦ ', 23 | ' 🐳 ∘°◦ ', 24 | ' 🐳 ∘°◦', 25 | '◦ 🐳 ∘°', 26 | '°◦ 🐳 ∘', 27 | '∘°◦ 🐳 ', 28 | ' ∘°◦ 🐳 ', 29 | ' ∘°◦ 🐳', 30 | ]; 31 | this.currentFrame = 0; 32 | this.loadingInterval = 0; 33 | this.loadingTimeout = 0; 34 | } 35 | 36 | public spinnerStart(timeDelay: number = 1500) { 37 | this.loadingTimeout = setTimeout(() => { 38 | this.showSpinner(); 39 | }, timeDelay); 40 | } 41 | 42 | private showSpinner() { 43 | this.loadingInterval = setInterval(() => { 44 | this.term.write(`\r${this.spinnerFrames[this.currentFrame]} 실행 중...`); 45 | this.currentFrame = (this.currentFrame + 1) % this.spinnerFrames.length; 46 | }, 80); 47 | } 48 | 49 | public spinnerStop() { 50 | if (this.loadingTimeout) { 51 | clearTimeout(this.loadingTimeout); 52 | this.loadingTimeout = 0; 53 | } 54 | clearInterval(this.loadingInterval); 55 | this.term.write('\r\x1b[2K'); 56 | } 57 | 58 | public hostSpinnerStart(timeDelay: number = 500) { 59 | this.loadingTimeout = setTimeout(() => { 60 | this.term.write('\r도커 컨테이너 준비중...\r\n'); 61 | this.showHostSpinner(); 62 | }, timeDelay); 63 | } 64 | 65 | private showHostSpinner() { 66 | this.loadingInterval = setInterval(() => { 67 | this.term.write(`\r${this.hostSpinnerFrames[this.currentFrame]}`); 68 | this.currentFrame = (this.currentFrame + 1) % this.hostSpinnerFrames.length; 69 | }, 200); 70 | } 71 | 72 | public hostSpinnerStop() { 73 | if (this.loadingTimeout) { 74 | clearTimeout(this.loadingTimeout); 75 | this.loadingTimeout = 0; 76 | } 77 | clearInterval(this.loadingInterval); 78 | this.term.clear(); 79 | this.term.write('\r\x1b[2K'); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /frontend/src/utils/sidebarUtils.ts: -------------------------------------------------------------------------------- 1 | import { SidebarState } from '../types/sidebar'; 2 | 3 | export const updateSidebarState = (states: Array, quizNumber: number) => { 4 | states.forEach((state) => { 5 | const currentQuizNum = Number(state.path.split('/').slice(-1)); 6 | if (state.pageType === 'quiz' && currentQuizNum <= quizNumber) { 7 | state.solved = true; 8 | } 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/utils/terminalUtils.ts: -------------------------------------------------------------------------------- 1 | import { Terminal } from '@xterm/xterm'; 2 | import { FitAddon } from '@xterm/addon-fit'; 3 | import { BrowserClipboardProvider, ClipboardAddon } from '@xterm/addon-clipboard'; 4 | import { requestCommandResult } from '../api/quiz'; 5 | 6 | export function createTerminal(container: HTMLElement): { 7 | terminal: Terminal; 8 | clipboardProvider: BrowserClipboardProvider; 9 | fitAddon: FitAddon; 10 | } { 11 | const terminal = new Terminal({ 12 | cursorBlink: true, 13 | fontFamily: '"Noto Sans Mono", "Noto Sans KR", courier-new, courier, monospace', 14 | fontSize: 14, 15 | rows: 13, 16 | fontWeight: '300', 17 | }); 18 | 19 | const clipboardProvider = new BrowserClipboardProvider(); 20 | const clipboardAddon = new ClipboardAddon(clipboardProvider); 21 | const fitAddon = new FitAddon(); 22 | 23 | terminal.loadAddon(clipboardAddon); 24 | terminal.loadAddon(fitAddon); 25 | 26 | terminal.open(container); 27 | 28 | const handleResize = () => { 29 | terminal.resize(4, 13); 30 | fitAddon.fit(); 31 | }; 32 | 33 | window.addEventListener('resize', handleResize); 34 | 35 | const originalDispose = terminal.dispose.bind(terminal); 36 | terminal.dispose = () => { 37 | window.removeEventListener('resize', handleResize); 38 | clipboardAddon.dispose(); 39 | originalDispose(); 40 | }; 41 | 42 | return { terminal, clipboardProvider, fitAddon }; 43 | } 44 | 45 | const handleClear = (term: Terminal) => { 46 | term.clear(); 47 | term.write('\x1b[2K\r~$ '); 48 | }; 49 | 50 | export const handleBackspace = (term: Terminal, currentLine: string) => { 51 | if (currentLine.length > 0) { 52 | term.write('\b \b'); 53 | return currentLine.slice(0, -1); 54 | } 55 | return currentLine; 56 | }; 57 | 58 | export const handleEnter = async ( 59 | term: Terminal, 60 | command: string, 61 | handleCommandError: (term: Terminal, statusCode: number, errorMessage: string) => void, 62 | updateVisualization: (command: string) => Promise 63 | ) => { 64 | if (!command) { 65 | term.write('\r\n~$ '); 66 | return; 67 | } 68 | 69 | if (command === 'clear') { 70 | handleClear(term); 71 | return; 72 | } 73 | 74 | term.write('\r\n'); 75 | const commandResponse = await requestCommandResult(command, term, handleCommandError); 76 | 77 | if (commandResponse !== null) { 78 | term.write(commandResponse); 79 | } 80 | await updateVisualization(command); 81 | 82 | term.write('\r\n~$ '); 83 | }; 84 | 85 | export const handleDefaultKey = (term: Terminal, key: string, currentLine: string) => { 86 | term.write(key); 87 | return currentLine + key; 88 | }; 89 | 90 | export const isPrintableKey = (key: string): boolean => { 91 | // 아스키 32 ~ 126 사이는 출력 가능한 문자 92 | return key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) <= 126; 93 | }; 94 | -------------------------------------------------------------------------------- /frontend/src/utils/visualizationUtils.ts: -------------------------------------------------------------------------------- 1 | import { Visualization } from '../types/visualization'; 2 | import { COLORS } from '../constant/visualization'; 3 | import { DOCKER_OPERATIONS } from '../types/visualization'; 4 | import { IMAGEID_PREFIX_INDEX } from '../constant/visualization'; 5 | 6 | export const setColorToElements = (prevElements: Visualization, newElements: Visualization) => { 7 | const initImages = updateImageColors(prevElements, newElements); 8 | const initContainers = updateContainerColors(prevElements, newElements); 9 | 10 | return { initImages, initContainers }; 11 | }; 12 | 13 | export const updateImageColors = (prevElements: Visualization, newElements: Visualization) => { 14 | const { images: prevImages } = prevElements; 15 | const { images: newImages } = newElements; 16 | return newImages.map((newImage) => { 17 | const prevImage = prevImages.find((img) => img.id === newImage.id); 18 | if (prevImage && Object.keys(prevImage).includes('color')) { 19 | return prevImage; 20 | } 21 | const newData = { 22 | ...newImage, 23 | color: getNotUsedColor(prevElements), 24 | }; 25 | prevImages.push(newData); 26 | return newData; 27 | }); 28 | }; 29 | 30 | const getNotUsedColor = (prevElements: Visualization) => { 31 | const { images } = prevElements; 32 | const notUsedColors = COLORS.filter((color) => { 33 | return !images.some((image) => image.color === color); 34 | }); 35 | return notUsedColors[0]; 36 | }; 37 | 38 | export const updateContainerColors = (prevElements: Visualization, newElements: Visualization) => { 39 | const { images: coloredImages } = prevElements; 40 | const { containers } = newElements; 41 | return containers.map((container) => { 42 | const image = coloredImages.find((image) => { 43 | return compareImageId(image.id, container.image) || image.name === container.image; 44 | }); 45 | return { 46 | ...container, 47 | color: image?.color, 48 | }; 49 | }); 50 | }; 51 | 52 | const compareImageId = (imageId: string, containerImage: string) => { 53 | const containerImageLen = containerImage.length; 54 | return ( 55 | imageId.slice(IMAGEID_PREFIX_INDEX, IMAGEID_PREFIX_INDEX + containerImageLen) === 56 | containerImage 57 | ); 58 | }; 59 | 60 | const isChangedContainerStatus = (prevElements: Visualization, newElements: Visualization) => { 61 | const { containers: prevContainers } = prevElements; 62 | const { containers: currentContainers } = newElements; 63 | return prevContainers.some((prevContainer) => { 64 | const matchedContainer = currentContainers.find( 65 | (currentContainer) => currentContainer.id === prevContainer.id 66 | ); 67 | if (!matchedContainer) { 68 | throw new Error('isChangedContainerStatus함수 undefined 에러'); 69 | } 70 | return matchedContainer.status !== prevContainer.status; 71 | }); 72 | }; 73 | 74 | export const getDockerOperation = (prevElements: Visualization, newElements: Visualization) => { 75 | const { images: newImages, containers: newContainers } = newElements; 76 | const { images: prevImages, containers: prevContainers } = prevElements; 77 | if (newImages.length > prevImages.length && newContainers.length === prevContainers.length) 78 | return DOCKER_OPERATIONS.IMAGE_PULL; 79 | if (newImages.length < prevImages.length) return DOCKER_OPERATIONS.IMAGE_DELETE; 80 | if (prevImages.length === newImages.length && prevContainers.length < newContainers.length) 81 | return DOCKER_OPERATIONS.CONTAINER_CREATE; 82 | if (prevImages.length < newImages.length && prevContainers.length < newContainers.length) 83 | return DOCKER_OPERATIONS.CONTAINER_RUN; 84 | if (prevContainers.length > newContainers.length) return DOCKER_OPERATIONS.CONTAINER_DELETE; 85 | if (isChangedContainerStatus(prevElements, newElements)) 86 | return DOCKER_OPERATIONS.CONTAINER_STATUS_CHANGED; 87 | }; 88 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import flowbite from 'flowbite-react/tailwind'; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: ['./index.html', './src/**/*.{js,jsx,tsx,ts}', flowbite.content()], 6 | theme: { 7 | extend: { 8 | fontFamily: { 9 | pretendard: ['Pretendard', 'sans-serif'], 10 | }, 11 | colors: { 12 | 'Moby-Blue': '#1D63ED', 13 | 'Dark-Blue': '#00084D', 14 | 'Light-Blue': '#E5F2FC', 15 | 'Off-Black': '#17191E', 16 | 'Secondary-Green': '#58C126', 17 | 'Secondary-Red': '#C12626', 18 | 'Stopped-Status-Color': '#FF0000', 19 | 'Running-Status-Color': '#00FF00', 20 | 'Restarting-Status-Color': '#FFFF00', 21 | }, 22 | }, 23 | 24 | keyframes: { 25 | showAndHideFirst: { 26 | '0%': { opacity: '0', visibility: 'hidden' }, 27 | '1%, 14.28%': { opacity: '1', visibility: 'visible' }, 28 | '14.29%, 100%': { opacity: '0', visibility: 'hidden' }, 29 | }, 30 | showAndHideSecond: { 31 | '0%, 14.28%': { opacity: '0', visibility: 'hidden' }, 32 | '14.29%, 28.57%': { opacity: '1', visibility: 'visible' }, 33 | '28.58%, 100%': { opacity: '0', visibility: 'hidden' }, 34 | }, 35 | showAndHideThird: { 36 | '0%, 28.57%': { opacity: '0', visibility: 'hidden' }, 37 | '28.58%, 42.86%': { opacity: '1', visibility: 'visible' }, 38 | '42.87%, 100%': { opacity: '0', visibility: 'hidden' }, 39 | }, 40 | showAndHideFourth: { 41 | '0%, 42.86%': { opacity: '0', visibility: 'hidden' }, 42 | '42.87%, 57.14%': { opacity: '1', visibility: 'visible' }, 43 | '57.15%, 100%': { opacity: '0', visibility: 'hidden' }, 44 | }, 45 | showAndHideFifth: { 46 | '0%, 57.14%': { opacity: '0', visibility: 'hidden' }, 47 | '57.15%, 71.43%': { opacity: '1', visibility: 'visible' }, 48 | '71.44%, 100%': { opacity: '0', visibility: 'hidden' }, 49 | }, 50 | showAndHideSixth: { 51 | '0%, 71.43%': { opacity: '0', visibility: 'hidden' }, 52 | '71.44%, 85.71%': { opacity: '1', visibility: 'visible' }, 53 | '85.72%, 100%': { opacity: '0', visibility: 'hidden' }, 54 | }, 55 | showAndHideSeventh: { 56 | '0%, 85.71%': { opacity: '0', visibility: 'hidden' }, 57 | '85.72%, 100%': { opacity: '1', visibility: 'visible' }, 58 | '100%': { opacity: '0', visibility: 'hidden' }, 59 | }, 60 | ping: { 61 | '75%, 100%': { 62 | transform: 'scale(2)', 63 | opacity: 0, 64 | }, 65 | }, 66 | }, 67 | animation: { 68 | showAndHideFirst: 'showAndHideFirst 2s linear forwards', 69 | showAndHideSecond: 'showAndHideSecond 2s linear forwards', 70 | showAndHideThird: 'showAndHideThird 2s linear forwards', 71 | showAndHideFourth: 'showAndHideFourth 2s linear forwards', 72 | showAndHideFifth: 'showAndHideFifth 2s linear forwards', 73 | showAndHideSixth: 'showAndHideSixth 2s linear forwards', 74 | showAndHideSeventh: 'showAndHideSeventh 2s linear forwards', 75 | ping: `ping 1s cubic-bezier(0, 0, 0.2, 1) infinite`, 76 | }, 77 | }, 78 | plugins: [flowbite.plugin()], 79 | }; 80 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "Bundler", 13 | "allowImportingTsExtensions": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedIndexedAccess": true 25 | }, 26 | "include": ["src"] 27 | } -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "target": "ES2022", 6 | "lib": ["ES2023"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "Bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedIndexedAccess": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from 'vite'; 2 | import react from '@vitejs/plugin-react-swc'; 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig(({ mode }) => { 6 | const env = loadEnv(mode, '../'); 7 | 8 | const apiHost = env.VITE_PROXY_HOST || 'localhost'; 9 | const apiPort = env.VITE_PROXY_PORT || '3000'; 10 | const apiUrl = `http://${apiHost}:${apiPort}`; 11 | 12 | return { 13 | plugins: [react()], 14 | server: { 15 | proxy: { 16 | '/api': { 17 | target: apiUrl, 18 | changeOrigin: true, 19 | }, 20 | }, 21 | }, 22 | }; 23 | }); 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web34-learndocker", 3 | "version": "1.0.0", 4 | "description": "## 프로젝트 개요 > 고래🐳와 함께 자신만의 Docker 환경에서 학습해볼까요?", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "pnpm -F frontend build && pnpm -F backend start", 8 | "dev": "concurrently --raw \"pnpm -F backend start:dev\" \"pnpm -F frontend dev\"", 9 | "lint": "eslint . ", 10 | "prettier": "prettier --write ." 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "@eslint/js": "^9.14.0", 16 | "@types/eslint__js": "^8.42.3", 17 | "@typescript-eslint/eslint-plugin": "^8.13.0", 18 | "concurrently": "^9.1.0", 19 | "eslint": "^9.14.0", 20 | "eslint-config-prettier": "^9.1.0", 21 | "eslint-plugin-react": "^7.37.2", 22 | "eslint-plugin-react-hooks": "^5.0.0", 23 | "eslint-plugin-react-refresh": "^0.4.14", 24 | "globals": "^15.12.0", 25 | "prettier": "^3.3.3", 26 | "typescript": "^5.6.3", 27 | "typescript-eslint": "^8.13.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'frontend/' 3 | - 'backend/' 4 | -------------------------------------------------------------------------------- /sandbox/host-container/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker:dind 2 | 3 | # learndocker.io가 가리킬 host IP 주소 (172.19.0.1)에 HTTP 요청을 허용하기 위해 필요함 4 | RUN mkdir -p /etc/docker 5 | RUN echo '{ "insecure-registries":["learndocker.io"] }' > /etc/docker/daemon.json 6 | -------------------------------------------------------------------------------- /sandbox/host-container/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | dind: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | image: dind 7 | privileged: true 8 | ports: 9 | - "0:2375" 10 | environment: 11 | - DOCKER_TLS_CERTDIR= 12 | extra_hosts: 13 | - "learndocker.io:172.17.0.1" 14 | network_mode: "bridge" -------------------------------------------------------------------------------- /sandbox/quiz-images/hello-world/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS builder 2 | WORKDIR /app 3 | COPY hello.c . 4 | RUN apk add --no-cache gcc musl-dev \ 5 | && gcc -static -Os -fno-ident -fdata-sections -ffunction-sections \ 6 | -fno-asynchronous-unwind-tables -fno-unwind-tables \ 7 | -Wl,--gc-sections -Wl,--strip-all \ 8 | -o hello hello.c 9 | 10 | FROM scratch 11 | COPY --from=builder /app/hello /hello 12 | ENTRYPOINT [ "/hello" ] -------------------------------------------------------------------------------- /sandbox/quiz-images/hello-world/hello.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | int main() { 4 | char answer[45] = "부스트캠프 웹모바일 9기 화이팅!"; 5 | printf("Answer: %s\n", answer); 6 | return 0; 7 | } -------------------------------------------------------------------------------- /sandbox/quiz-images/joke/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS builder 2 | WORKDIR /app 3 | COPY joke.c . 4 | RUN apk add --no-cache gcc musl-dev \ 5 | && gcc -static -Os -fno-ident -fdata-sections -ffunction-sections \ 6 | -fno-asynchronous-unwind-tables -fno-unwind-tables \ 7 | -Wl,--gc-sections -Wl,--strip-all \ 8 | -o joke joke.c 9 | 10 | FROM scratch 11 | COPY --from=builder /app/joke /joke 12 | ENTRYPOINT [ "/joke" ] -------------------------------------------------------------------------------- /sandbox/quiz-images/joke/joke.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | static sig_atomic_t running = 1; 5 | 6 | void signal_handler(int signum) { 7 | if (signum == SIGTERM) { 8 | running = 0; 9 | } 10 | } 11 | 12 | size_t strlen(const char *str) { 13 | const char *s; 14 | for (s = str; *s; ++s) 15 | ; 16 | return s - str; 17 | } 18 | 19 | int main() { 20 | signal(SIGTERM, signal_handler); 21 | 22 | const char *msg = "넌센스 퀴즈입니다!\n우주인이 술 마시는 장소는?\n"; 23 | 24 | write(STDOUT_FILENO, msg, strlen(msg)); 25 | 26 | while(running) { 27 | sleep(2); 28 | } 29 | 30 | 31 | return 0; 32 | } -------------------------------------------------------------------------------- /sandbox/registry/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | registry: 3 | image: registry:2 4 | ports: 5 | - "80:80" 6 | environment: 7 | REGISTRY_HTTP_ADDR: 0.0.0.0:80 8 | restart: always 9 | network_mode: "bridge" -------------------------------------------------------------------------------- /sandbox/setup.ps1: -------------------------------------------------------------------------------- 1 | # .\setup.ps1 으로 실행하세요 2 | 3 | # Set working directory to script location 4 | Set-Location $PSScriptRoot 5 | 6 | # Build DinD image 7 | docker build -t dind:latest -f ./host-container/Dockerfile ./host-container 8 | 9 | # Build quiz images 10 | docker build -t localhost/hello-world -f ./quiz-images/hello-world/Dockerfile ./quiz-images/hello-world 11 | docker build -t localhost/joke -f ./quiz-images/joke/Dockerfile ./quiz-images/joke 12 | 13 | # Start Registry container 14 | docker compose -f ./registry/docker-compose.yml up -d 15 | 16 | Start-Sleep -Seconds 5 17 | 18 | # Push images to Registry 19 | docker push localhost/hello-world 20 | docker push localhost/joke -------------------------------------------------------------------------------- /sandbox/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")" 6 | 7 | # DinD 이미지 빌드 8 | docker build --tag dind:latest --file ./host-container/Dockerfile ./host-container 9 | 10 | # hello-world 이미지 빌드 11 | docker build --tag localhost/hello-world --file ./quiz-images/hello-world/Dockerfile ./quiz-images/hello-world 12 | 13 | # joke 이미지 빌드 14 | docker build --tag localhost/joke --file ./quiz-images/joke/Dockerfile ./quiz-images/joke 15 | 16 | # Registry 컨테이너 실행 17 | docker compose -f ./registry/docker-compose.yml up -d 18 | 19 | sleep 5 20 | 21 | # Registry에 퀴즈용 이미지 push 22 | docker push localhost/hello-world 23 | docker push localhost/joke 24 | --------------------------------------------------------------------------------