├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── downloads └── .gitkeep ├── nest-cli.json ├── package-lock.json ├── package.json ├── public ├── favicon.svg ├── repos │ └── .gitkeep ├── scripts │ ├── index.js │ └── ws.js └── styles │ ├── main.css │ ├── main.css.map │ └── main.scss ├── src ├── Custom │ └── custom-http.exception.ts ├── api.controller.ts ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── dl-ws │ ├── dl-ws.gateway.ts │ └── dl-ws.module.ts ├── file │ ├── file.controller.ts │ ├── file.module.ts │ └── file.service.ts ├── main.ts └── middlewares │ ├── index.ts │ └── rewrite-url.middlewares.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json └── views └── index.hbs /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /downloads/* 2 | !/downloads/.gitkeep 3 | 4 | /public/repos/* 5 | !/public/repos/.gitkeep 6 | 7 | .sass-cache/ 8 | 9 | # compiled output 10 | /dist 11 | /node_modules 12 | 13 | # Logs 14 | logs 15 | *.log 16 | npm-debug.log* 17 | pnpm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | lerna-debug.log* 21 | 22 | # OS 23 | .DS_Store 24 | 25 | # Tests 26 | /coverage 27 | /.nyc_output 28 | 29 | # IDEs and editors 30 | /.idea 31 | .project 32 | .classpath 33 | .c9/ 34 | *.launch 35 | .settings/ 36 | *.sublime-workspace 37 | 38 | # IDE - VSCode 39 | .vscode/* 40 | !.vscode/settings.json 41 | !.vscode/tasks.json 42 | !.vscode/launch.json 43 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Web Repo Looker

2 | 3 |

4 | Web Repo Looker Logo 5 |

6 | 7 | The Web Repo Looker projects is a tool to easily visualize any web front-end project without any setup or installation needed. 8 | 9 | For now, only vanilla project are barely supported, in the long term, the objective is to be able to visualize any project, whether in Angular, React, Vue, Svelte as well as any build tool: Vite, Webpack, Parcel, SWC... 10 | 11 | ## Installation 12 | 13 | ```bash 14 | $ npm install 15 | ``` 16 | 17 | ## Running the app 18 | 19 | ```bash 20 | # development 21 | $ npm run start 22 | 23 | # watch mode 24 | $ npm run start:dev 25 | 26 | # production mode 27 | $ npm run start:prod 28 | ``` 29 | 30 | ## Test 31 | 32 | ```bash 33 | # unit tests 34 | $ npm run test 35 | 36 | # e2e tests 37 | $ npm run test:e2e 38 | 39 | # test coverage 40 | $ npm run test:cov 41 | ``` 42 | 43 | ## TODO 44 | 45 | - fix Error: ENOENT: no such file or directory, open 'public/repos/Charles-Chrismann/simple-html-main/assets/css/style.css' while writing files 46 | - Add front-end details/summary to display log messages 47 | 48 | ## License 49 | 50 | This project is [MIT licensed](LICENSE). -------------------------------------------------------------------------------- /downloads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charles-Chrismann/web-repo-looker/f08f60d953dd6540d79adb1da705f665cf26de0f/downloads/.gitkeep -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-repo-looker", 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": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 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 | "@nestjs/common": "^10.0.0", 24 | "@nestjs/core": "^10.0.0", 25 | "@nestjs/mapped-types": "*", 26 | "@nestjs/platform-express": "^10.0.0", 27 | "@nestjs/platform-socket.io": "^10.1.3", 28 | "@nestjs/serve-static": "^4.0.0", 29 | "@nestjs/websockets": "^10.1.3", 30 | "axios": "^1.4.0", 31 | "hbs": "^4.2.0", 32 | "jszip": "^3.10.1", 33 | "reflect-metadata": "^0.1.13", 34 | "rxjs": "^7.8.1", 35 | "uuid": "^9.0.0" 36 | }, 37 | "devDependencies": { 38 | "@nestjs/cli": "^10.0.0", 39 | "@nestjs/schematics": "^10.0.0", 40 | "@nestjs/testing": "^10.0.0", 41 | "@types/express": "^4.17.17", 42 | "@types/jest": "^29.5.2", 43 | "@types/node": "^20.3.1", 44 | "@types/supertest": "^2.0.12", 45 | "@typescript-eslint/eslint-plugin": "^5.59.11", 46 | "@typescript-eslint/parser": "^5.59.11", 47 | "eslint": "^8.42.0", 48 | "eslint-config-prettier": "^8.8.0", 49 | "eslint-plugin-prettier": "^4.2.1", 50 | "jest": "^29.5.0", 51 | "prettier": "^2.8.8", 52 | "source-map-support": "^0.5.21", 53 | "supertest": "^6.3.3", 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 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/repos/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Charles-Chrismann/web-repo-looker/f08f60d953dd6540d79adb1da705f665cf26de0f/public/repos/.gitkeep -------------------------------------------------------------------------------- /public/scripts/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function findInsertionIndex(sortedArray, targetString) { 4 | let left = 0; 5 | let right = sortedArray.length - 1; 6 | 7 | while (left <= right) { 8 | const mid = Math.floor((left + right) / 2); 9 | const midValue = sortedArray[mid]; 10 | 11 | if (midValue === targetString) { 12 | return mid; 13 | } else if (midValue < targetString) { 14 | left = mid + 1; 15 | } else { 16 | right = mid - 1; 17 | } 18 | } 19 | return left; 20 | } 21 | 22 | const form = document.querySelector('form'); 23 | const result = document.querySelector('#result'); 24 | const userTemplate = document.querySelector('#user-template'); 25 | const repoTemplate = document.querySelector('#repo-template'); 26 | const statusEl = document.querySelector('#status'); 27 | 28 | document.querySelector('#result .fa-regular.fa-copy').addEventListener('click', (e) => { 29 | navigator.clipboard.writeText(location + result.querySelector('.result').textContent.substring(1)).then( 30 | () => {}, 31 | (err) => {} 32 | ); 33 | }); 34 | 35 | 36 | form.addEventListener('submit', (e) => { 37 | e.preventDefault(); 38 | result.classList.add('hidden'); 39 | statusEl.classList.remove('hidden'); 40 | fetch(`/api/file/clone`, { 41 | method: 'POST', 42 | headers: { 43 | 'Content-Type': 'application/json', 44 | }, 45 | body: JSON.stringify({ 46 | url: form.elements.url.value, 47 | socketId: socketId, 48 | }), 49 | }).then((response) => response.json()) 50 | .then((data) => { 51 | result.classList.remove('hidden', 'error', 'success'); 52 | statusEl.classList.add('hidden'); 53 | if (data.message) { 54 | result.classList.add('error'); 55 | result.querySelector('.result').textContent = data.message; 56 | } else { 57 | result.classList.add('success'); 58 | result.querySelector('.result').textContent = data.url; 59 | result.querySelector('.infos > a').attributes.href.value = data.url; 60 | 61 | let user = Array.from(document.querySelectorAll('#users > li')) 62 | .find((el) => el.querySelector('.user__infos > h4 > a').textContent === data.user); 63 | 64 | if (!user) { 65 | const userClone = userTemplate.content.cloneNode(true); 66 | console.log(userClone); 67 | userClone.querySelector('.user__infos > h4 > a').textContent = data.user; 68 | userClone.querySelector('.user__infos > h4 > a').attributes.href.value = 'https://github.com/' + data.user; 69 | userClone.querySelector('.user__infos > a').attributes.href.value = 'https://github.com/' + data.user; 70 | userClone.querySelector('.user__infos > a > img').src = `https://github.com/${data.user}.png?size=32`; 71 | userClone.querySelector('.user__infos > a > img').alt = data.user; 72 | const insertionIndex = findInsertionIndex(Array.from(document.querySelectorAll('#users > li')).map((el) => el.querySelector('.user__infos > h4 > a').textContent), data.user); 73 | 74 | if(insertionIndex === 0) document.querySelector('#users > li').before(userClone); 75 | else document.querySelectorAll('#users > li')[insertionIndex - 1].after(userClone); 76 | } 77 | user ??= Array.from(document.querySelectorAll('#users > li')) 78 | .find((el) => el.querySelector('.user__infos > h4 > a').textContent === data.user); 79 | 80 | const existringRepo = Array.from(user.querySelectorAll('.repos > li > a')).map((el) => el.attributes.href.value).includes(data.url); 81 | if(!existringRepo) { 82 | const repoClone = repoTemplate.content.cloneNode(true); 83 | repoClone.querySelector('li > a').textContent = data.url.split('/').at(3); 84 | repoClone.querySelector('li > a').attributes.href.value = data.url; 85 | user.querySelector('.repos').appendChild(repoClone); 86 | } 87 | } 88 | }).catch((err) => { 89 | console.log(err); 90 | }) 91 | }) 92 | 93 | 94 | -------------------------------------------------------------------------------- /public/scripts/ws.js: -------------------------------------------------------------------------------- 1 | const socket = io(); 2 | let socketId = null; 3 | 4 | socket.on('connect', function() { 5 | console.log('Connected', socket.id); 6 | socketId = socket.id; 7 | }); 8 | socket.on('progress', function(data) { 9 | console.log('progress', data); 10 | statusEl.querySelector('.step').textContent = `Step: ${data.step}`; 11 | statusEl.querySelector('.percentage').textContent = `${data.progress}%`; 12 | statusEl.querySelector('.progress') 13 | .style.setProperty('--progress', data.progress + '%'); 14 | }); 15 | socket.on('exception', function(data) { 16 | console.log('event', data); 17 | }); 18 | socket.on('disconnect', function() { 19 | console.log('Disconnected'); 20 | }); -------------------------------------------------------------------------------- /public/styles/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | border: none; 6 | outline: none; 7 | font-family: "Roboto", sans-serif; 8 | color: unset; 9 | list-style: none; 10 | text-decoration: none; 11 | } 12 | 13 | body { 14 | background: #010409; 15 | padding: 0 10%; 16 | } 17 | body h1 { 18 | color: #ffffff; 19 | margin-top: 2rem; 20 | margin-bottom: 2rem; 21 | } 22 | body .clone-bar { 23 | width: 100%; 24 | margin-bottom: 4rem; 25 | } 26 | body .clone-bar form { 27 | width: 100%; 28 | height: 100%; 29 | display: flex; 30 | justify-content: space-between; 31 | gap: 16px; 32 | } 33 | body .clone-bar form input { 34 | width: 100%; 35 | border-radius: 6px; 36 | padding: 0 1rem; 37 | } 38 | body .clone-bar form button { 39 | aspect-ratio: 1/1; 40 | height: 64px; 41 | border-radius: 6px; 42 | font-size: 24px; 43 | } 44 | body .clone-bar #status { 45 | margin-top: 1rem; 46 | height: 32px; 47 | width: 100%; 48 | border-radius: 6px; 49 | display: flex; 50 | justify-content: space-between; 51 | align-items: center; 52 | position: relative; 53 | overflow: hidden; 54 | padding: 0 1rem; 55 | font-weight: bold; 56 | border: solid 1px orange; 57 | color: white; 58 | } 59 | body .clone-bar #status.hidden { 60 | display: none; 61 | } 62 | body .clone-bar #status .progress { 63 | position: absolute; 64 | left: 0; 65 | top: 0; 66 | height: 100%; 67 | width: var(--progress); 68 | background-color: orange; 69 | z-index: -1; 70 | } 71 | body .clone-bar h4#result { 72 | margin-top: 16px; 73 | height: 64px; 74 | border: solid 1px; 75 | background: #161b22; 76 | display: flex; 77 | align-items: center; 78 | padding: 0 1rem; 79 | justify-content: space-between; 80 | border-color: var(--color); 81 | color: var(--color); 82 | border-radius: 6px; 83 | } 84 | body .clone-bar h4#result.hidden { 85 | display: none; 86 | } 87 | body .clone-bar h4#result .infos { 88 | display: none; 89 | } 90 | body .clone-bar h4#result .infos i { 91 | cursor: pointer; 92 | } 93 | body .clone-bar h4#result.success { 94 | --color: green; 95 | } 96 | body .clone-bar h4#result.success .infos { 97 | display: block; 98 | } 99 | body .clone-bar h4#result.error { 100 | --color: red; 101 | } 102 | body h2.users { 103 | color: #ffffff; 104 | padding-bottom: 16px; 105 | } 106 | body #users li.user { 107 | list-style: none; 108 | background: #161b22; 109 | border: solid 1px #30363d; 110 | padding: 0.75rem 0.5rem; 111 | border-radius: 6px; 112 | margin-bottom: 1.5rem; 113 | } 114 | body #users li.user .user__infos { 115 | display: flex; 116 | gap: 0.5rem; 117 | align-items: center; 118 | } 119 | body #users li.user .user__infos img { 120 | aspect-ratio: 1/1; 121 | border-radius: 50%; 122 | } 123 | body #users li.user ul.repos { 124 | padding-left: 40px; 125 | } 126 | 127 | /*# sourceMappingURL=main.css.map */ 128 | -------------------------------------------------------------------------------- /public/styles/main.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":["main.scss"],"names":[],"mappings":"AAAA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAEA;EACI;;AAIR;EACI;;AAEA;EACI;;AAIR;EACI;;AAKZ;EACI;EACA;;AAIA;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI","file":"main.css"} -------------------------------------------------------------------------------- /public/styles/main.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | border: none; 6 | outline: none; 7 | font-family: 'Roboto', sans-serif; 8 | color: unset; 9 | list-style: none; 10 | text-decoration: none; 11 | } 12 | 13 | body { 14 | background: #010409; 15 | padding: 0 10%; 16 | 17 | h1 { 18 | color: #ffffff; 19 | margin-top: 2rem; 20 | margin-bottom: 2rem; 21 | } 22 | 23 | .clone-bar { 24 | width: 100%; 25 | margin-bottom: 4rem; 26 | 27 | form { 28 | width: 100%; 29 | height: 100%; 30 | display: flex; 31 | justify-content: space-between; 32 | gap: 16px; 33 | 34 | input { 35 | width: 100%; 36 | border-radius: 6px; 37 | padding: 0 1rem; 38 | } 39 | 40 | button { 41 | aspect-ratio: 1 / 1; 42 | height: 64px; 43 | border-radius: 6px; 44 | font-size: 24px; 45 | } 46 | } 47 | 48 | #status { 49 | margin-top: 1rem; 50 | height: 32px; 51 | width: 100%; 52 | border-radius: 6px; 53 | display: flex; 54 | justify-content: space-between; 55 | align-items: center; 56 | position: relative; 57 | overflow: hidden; 58 | padding: 0 1rem; 59 | font-weight: bold; 60 | border: solid 1px orange; 61 | color: white; 62 | 63 | &.hidden { 64 | display: none; 65 | } 66 | 67 | .progress { 68 | position: absolute; 69 | left: 0; 70 | top: 0; 71 | height: 100%; 72 | width: var(--progress); 73 | background-color: orange; 74 | z-index: -1; 75 | } 76 | } 77 | 78 | h4#result { 79 | margin-top: 16px; 80 | height: 64px; 81 | border: solid 1px; 82 | background: #161b22; 83 | display: flex; 84 | align-items: center; 85 | padding: 0 1rem; 86 | justify-content: space-between; 87 | 88 | border-color: var(--color); 89 | color: var(--color); 90 | border-radius: 6px; 91 | 92 | &.hidden { 93 | display: none; 94 | } 95 | 96 | .infos { 97 | display: none; 98 | 99 | i { 100 | cursor: pointer; 101 | } 102 | } 103 | 104 | &.success { 105 | --color: green; 106 | 107 | .infos { 108 | display: block; 109 | } 110 | } 111 | 112 | &.error { 113 | --color: red; 114 | } 115 | } 116 | } 117 | 118 | h2.users { 119 | color: #ffffff; 120 | padding-bottom: 16px; 121 | } 122 | #users { 123 | 124 | li.user { 125 | list-style: none; 126 | background: #161b22; 127 | border: solid 1px #30363d; 128 | padding: 0.75rem 0.5rem; 129 | border-radius: 6px; 130 | margin-bottom: 1.5rem; 131 | 132 | .user__infos { 133 | display: flex; 134 | gap: 0.5rem; 135 | align-items: center; 136 | 137 | img { 138 | aspect-ratio: 1 / 1; 139 | border-radius: 50%; 140 | } 141 | } 142 | 143 | ul.repos { 144 | padding-left: 40px; 145 | } 146 | } 147 | } 148 | } -------------------------------------------------------------------------------- /src/Custom/custom-http.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export class CustomHttpException extends HttpException { 4 | constructor(response: ErrorResponse) { 5 | super(response, response.statusCode); 6 | } 7 | } 8 | 9 | export interface ErrorResponse { 10 | statusCode: number; 11 | message: string; 12 | error: string; 13 | } -------------------------------------------------------------------------------- /src/api.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Post } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller('api') 5 | export class ApiController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get('') 9 | getHello(): string { 10 | return 'rien' 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Render } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import * as fs from 'fs'; 4 | 5 | @Controller() 6 | export class AppController { 7 | constructor(private readonly appService: AppService) {} 8 | 9 | @Get() 10 | @Render('index') 11 | async root() { 12 | let users = [] as { user: string, repos: string[] }[] 13 | const repos = (await fs.promises.readdir('public/repos')).filter(user => user !== '.gitkeep') 14 | await Promise.all(repos.map(async user => { 15 | const userData = JSON.parse((await fs.promises.readFile(`public/repos/${user}/manifest.json`)).toString()) 16 | users.push({ 17 | user, 18 | repos: userData.repos.map(repo => `${repo.name}-${repo.branch}`) 19 | }); 20 | })) 21 | 22 | return { 23 | message: 'Hello world! rendered', 24 | users 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { join } from 'path'; 5 | import { ServeStaticModule } from '@nestjs/serve-static'; 6 | import { ApiController } from './api.controller'; 7 | import { FileModule } from './file/file.module'; 8 | import { DlWsModule } from './dl-ws/dl-ws.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | ServeStaticModule.forRoot({ 13 | rootPath: join(__dirname, '..', 'public'), 14 | }), 15 | FileModule, 16 | ], 17 | controllers: [AppController, ApiController], 18 | providers: [ 19 | AppService, 20 | ], 21 | }) 22 | export class AppModule {} 23 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/dl-ws/dl-ws.gateway.ts: -------------------------------------------------------------------------------- 1 | import { OnGatewayConnection, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; 2 | import { Server } from 'socket.io'; 3 | 4 | @WebSocketGateway() 5 | export class DlWsGateway implements OnGatewayConnection { 6 | @WebSocketServer() 7 | server: Server; 8 | 9 | handleConnection(client: any, ...args: any[]) { 10 | console.log(`connected (${this.server.engine.clientsCount})`, client.id) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/dl-ws/dl-ws.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { DlWsGateway } from './dl-ws.gateway'; 3 | 4 | @Module({ 5 | providers: [DlWsGateway] 6 | }) 7 | export class DlWsModule {} 8 | -------------------------------------------------------------------------------- /src/file/file.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, HttpException, HttpStatus, Post, Req } from '@nestjs/common'; 2 | import { FileService } from './file.service'; 3 | import { AxiosError } from 'axios'; 4 | import { CustomHttpException } from 'src/Custom/custom-http.exception'; 5 | 6 | @Controller('file') 7 | export class FileController { 8 | constructor(private readonly fileService: FileService) {} 9 | 10 | @Post('clone') 11 | async downloadZip(@Body('url') url: string, @Body('socketId') socketId: string): Promise<{ url: string; user: string; repo: string }> { 12 | try { 13 | const publicUrl = await this.fileService.downloadFile(url, socketId) 14 | const [user, repo] = url.split('/').slice(3, 5) 15 | return { 16 | url: publicUrl, 17 | user, 18 | repo 19 | }; 20 | } catch (error) { 21 | if(error instanceof AxiosError && error.response.status === 404) { 22 | throw new CustomHttpException({ 23 | statusCode: HttpStatus.NOT_FOUND, 24 | message: 'Repository not found', 25 | error: 'Not Found', 26 | }); 27 | } 28 | throw new HttpException({ 29 | statusCode: HttpStatus.INTERNAL_SERVER_ERROR, 30 | message: 'Internal server error', 31 | error: 'Internal Server Error', 32 | }, HttpStatus.INTERNAL_SERVER_ERROR); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/file/file.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { FileController } from './file.controller'; 3 | import { FileService } from './file.service'; 4 | import { DlWsGateway } from 'src/dl-ws/dl-ws.gateway'; 5 | 6 | @Module({ 7 | controllers: [FileController], 8 | providers: [FileService, DlWsGateway] 9 | }) 10 | export class FileModule {} 11 | -------------------------------------------------------------------------------- /src/file/file.service.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as util from 'util'; 3 | import { exec } from 'child_process'; 4 | import { Injectable } from '@nestjs/common'; 5 | import axios from 'axios'; 6 | const JsZip = require("jszip") 7 | import { v4 as uuidv4 } from 'uuid'; 8 | 9 | import { CustomHttpException } from 'src/Custom/custom-http.exception'; 10 | import { DlWsGateway } from 'src/dl-ws/dl-ws.gateway'; 11 | 12 | @Injectable() 13 | export class FileService { 14 | 15 | constructor(private readonly dlWsGateway: DlWsGateway) {} 16 | 17 | async downloadFile(url: string, socketId: string): Promise { 18 | const socket = this.dlWsGateway.server.sockets.sockets.get(socketId) 19 | 20 | const acceptedPaterns = [ 21 | /^https:\/\/github\.com\/[a-zA-Z0-9\-]{1,39}\/[a-zA-Z0-9-_]+\/archive\/refs\/heads\/.+\.zip\/?$/, 22 | /^https:\/\/github\.com\/[a-zA-Z0-9\-]{1,39}\/[a-zA-Z0-9-_]+\/?$/, 23 | /^https:\/\/github\.com\/[a-zA-Z0-9\-]{1,39}\/[a-zA-Z0-9-_]+\/tree\/[a-zA-Z0-9\-]+\/?$/ 24 | ] 25 | 26 | if(!acceptedPaterns.some(patern => patern.test(url))) throw new CustomHttpException({ 27 | statusCode: 400, 28 | message: 'Invalid url', 29 | error: 'Bad Request', 30 | }) 31 | 32 | let [username, repo] = url.split('/').slice(3, 5) 33 | 34 | const repoData = await axios({ 35 | url: `https://api.github.com/repos/${username}/${repo}`, 36 | method: 'GET', 37 | }); 38 | 39 | [username, repo] = repoData.data.full_name.split('/') 40 | 41 | let branch: string 42 | if(url.includes('.zip')) { 43 | branch = url.split('/').at(-2).split('.zip')[0] 44 | } else if (url.includes('tree')) { 45 | branch = url.split('/').at(-1) 46 | } else { 47 | branch = repoData.data.default_branch 48 | } 49 | 50 | console.log(username, repo, branch) 51 | // console.log(repoData.data) 52 | 53 | let response: any 54 | try { 55 | response = await axios({ 56 | url: `https://github.com/${username}/${repo}/archive/refs/heads/${branch}.zip`, 57 | method: 'GET', 58 | responseType: 'stream', 59 | }); 60 | } catch (error) { 61 | throw new CustomHttpException({ 62 | statusCode: 404, 63 | message: 'Repo not found', 64 | error: 'Not Found', 65 | }) 66 | } 67 | 68 | const zipName = `repo-${uuidv4()}.zip` 69 | const writer = fs.createWriteStream('downloads/' + zipName) 70 | 71 | response.data.pipe(writer) 72 | 73 | let downloadedSize = 0 74 | let lastpercentage = 0 75 | 76 | response.data.on('data', (chunk) => { 77 | if(!socketId) return 78 | downloadedSize += (chunk.length / 1024) / repoData.data.size * 100 79 | const percentage = Math.trunc(downloadedSize) 80 | if(percentage === lastpercentage || percentage % 10 !== 0) { 81 | lastpercentage = percentage 82 | return 83 | } 84 | lastpercentage = percentage 85 | if(socket) socket.emit('progress', { step: 'cloning', progress: percentage}) 86 | }) 87 | 88 | await new Promise((resolve, reject) => { 89 | writer.on('finish', resolve) 90 | writer.on('error', reject) 91 | }) 92 | 93 | if(socket) socket.emit('progress', { step: 'checking existing repos', progress: 0}) 94 | const data = await fs.promises.readFile('downloads/' + zipName); 95 | 96 | let userManifest: { username: string, repos: { name: string, branch: string }[] } 97 | try { 98 | userManifest = JSON.parse((await fs.promises.readFile(`public/repos/${username}/manifest.json`)).toString()) 99 | } catch (error) { 100 | await fs.promises.mkdir(`public/repos/${username}`, { recursive: true }) 101 | userManifest = { 102 | username, 103 | repos: [] 104 | } 105 | } 106 | if(!userManifest.repos.some(r => r.name === repo && r.branch === branch)) { 107 | userManifest.repos.push({ 108 | name: repo, 109 | branch, 110 | }) 111 | await fs.promises.writeFile(`public/repos/${username}/manifest.json`, JSON.stringify(userManifest)) 112 | } 113 | 114 | 115 | if(socket) socket.emit('progress', { step: 'unziping', progress: 0}) 116 | let foldersPromises: Promise[] = [] 117 | let filesPromises: Promise[] = [] 118 | let hasAPackageJson = false 119 | 120 | let zip = await JsZip.loadAsync(data) 121 | 122 | for (const [relativePath, zipEntry] of Object.entries(zip.files)) { 123 | if(relativePath.endsWith('/')) { 124 | foldersPromises.push(fs.promises.mkdir(`public/repos/${username}/${relativePath}`, { recursive: true })) 125 | } else { 126 | if(relativePath.endsWith('package.json') && relativePath.split('/').length === 2) hasAPackageJson = true 127 | filesPromises.push((async () => { 128 | let content = await (zipEntry as any).async('nodebuffer') 129 | fs.promises.writeFile(`public/repos/${username}/${relativePath}`, content) 130 | })() 131 | ) 132 | } 133 | } 134 | 135 | try { 136 | await Promise.all(foldersPromises) 137 | await Promise.all(filesPromises) 138 | } catch(err) { 139 | throw new Error('Failed to unzip') 140 | } 141 | fs.unlink('downloads/' + zipName, () => {}) 142 | 143 | let buildFolderName = ''; 144 | if(hasAPackageJson) { 145 | console.log('has a package.json, running npm install ...') 146 | try { 147 | if(socket) socket.emit('progress', { step: 'Installing dependencies ...', progress: 0}) 148 | await new Promise((resolve, reject) => { 149 | const npmInstallProcess = exec(`cd public/repos/${username}/${repo}-${branch} && npm install`); 150 | npmInstallProcess.stdout.on('data', (data) => { 151 | console.log(data); // Afficher les sorties de npm install dans la console 152 | if(socket) socket.emit('progress', { step: 'Installing dependencies ...', progress: 0, message: data}) 153 | }); 154 | npmInstallProcess.stderr.on('data', (data) => { 155 | console.error(data); // Afficher les erreurs de npm install dans la console 156 | }); 157 | npmInstallProcess.on('close', (code) => { 158 | if(code === 0) { 159 | resolve() 160 | } else { 161 | reject() 162 | } 163 | }); 164 | }) 165 | 166 | // Obtenez la liste des dossiers avant la construction 167 | const folderBeforeBuild = ( 168 | await fs.promises.readdir(`public/repos/${username}/${repo}-${branch}`, { withFileTypes: true }) 169 | ).filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name); 170 | 171 | if(socket) socket.emit('progress', { step: 'Building ...', progress: 0}) 172 | await new Promise((resolve, reject) => { 173 | // Exécutez la commande `npm run build` avec une redirection des sorties standard et d'erreur 174 | const npmRunBuildProcess = exec(`cd public/repos/${username}/${repo}-${branch} && npm run build`); 175 | npmRunBuildProcess.stdout.on('data', (data) => { 176 | console.log(data); // Afficher les sorties de npm run build dans la console 177 | if(socket) socket.emit('progress', { step: 'Building ...', progress: 0, message: data}) 178 | }); 179 | npmRunBuildProcess.stderr.on('data', (data) => { 180 | console.error(data); // Afficher les erreurs de npm run build dans la console 181 | }); 182 | npmRunBuildProcess.on('close', (code) => { 183 | if(code === 0) { 184 | resolve() 185 | } else { 186 | reject() 187 | } 188 | }); 189 | }) 190 | 191 | console.log('Build process completed successfully.'); 192 | 193 | const currentFolders = (await fs.promises.readdir(`public/repos/${username}/${repo}-${branch}`, { withFileTypes: true })).filter(dirent => dirent.isDirectory()).map(dirent => dirent.name) 194 | buildFolderName = currentFolders.find(folder => folderBeforeBuild.indexOf(folder) === -1) 195 | 196 | } catch (error) { 197 | console.log("Failed to run npm install") 198 | console.log(error) 199 | } 200 | } 201 | 202 | return `/repos/${username}/${repo}-${branch}/${buildFolderName}` 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { NestExpressApplication } from '@nestjs/platform-express'; 4 | import { join } from 'path'; 5 | import { RewriteUrlMiddleware } from './middlewares'; 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule); 9 | app.setGlobalPrefix('api', { 10 | exclude: ['', 'api'] 11 | }); 12 | app.use(RewriteUrlMiddleware) 13 | app.useStaticAssets(join(__dirname, '..', 'public')) 14 | app.setBaseViewsDir(join(__dirname, '..', 'views')); 15 | app.setViewEngine('hbs'); 16 | 17 | await app.listen(3000); 18 | } 19 | bootstrap(); 20 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export { default as RewriteUrlMiddleware } from './rewrite-url.middlewares'; -------------------------------------------------------------------------------- /src/middlewares/rewrite-url.middlewares.ts: -------------------------------------------------------------------------------- 1 | export default function RewriteUrlMiddleware(req, res, next) { 2 | if( 3 | // brak on http://localhost:3000/todo_react_app/build/favicon.ico 4 | !( 5 | req.url.endsWith('.html') 6 | || req.url.endsWith('/') 7 | || req.url.startsWith('/api') 8 | || req.url.startsWith('/repos') 9 | || req.url.startsWith('/socket.io') 10 | || req.url.startsWith('/scripts') 11 | || req.url.startsWith('/styles') 12 | || req.url.startsWith('/favicon.svg') 13 | ) && req.headers.referer && req.headers.referer.includes('/repos/') 14 | ) { 15 | const splitedReferer = req.headers.referer.split('/') 16 | const repoIndex = splitedReferer.findIndex((e) => e === 'repos') 17 | const username = splitedReferer[repoIndex + 1] 18 | const repo = splitedReferer[repoIndex + 2] 19 | 20 | // Will break if branch name contains '-' 21 | const branch = repo.split('-').at(-1) 22 | 23 | // prevent url like /{repoName}/build/... 24 | const splitedUrl = req.url.split('/') 25 | let repoNameIndexInUrl = splitedUrl.findIndex((e) => e === repo.split('-' + branch)[0]) // can break if repo name contains branch name 26 | if(repoNameIndexInUrl !== -1) splitedUrl.splice(repoNameIndexInUrl, 1) 27 | 28 | let startFinalUrlSplited = req.headers.referer.split('/').slice(3) 29 | if(startFinalUrlSplited.at(-1) !== '') startFinalUrlSplited.pop() 30 | startFinalUrlSplited = startFinalUrlSplited.join('/') 31 | 32 | req.url = `/${startFinalUrlSplited}${splitedUrl.join('/')}` 33 | } 34 | next() 35 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "public"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /views/index.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Web Repo Looker 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

Web Repo Looker

17 | 18 |
19 |
20 | 21 | 22 |
23 | 24 | 29 | 30 | 39 |
40 | 41 |

Already Cloned Repos

42 | 61 | 62 | 75 | 76 | 81 | 82 | 83 | 84 | --------------------------------------------------------------------------------