├── downloads └── .gitkeep ├── public ├── repos │ └── .gitkeep ├── styles │ ├── main.css.map │ ├── main.css │ └── main.scss ├── scripts │ ├── ws.js │ └── index.js └── favicon.svg ├── .prettierrc ├── src ├── middlewares │ ├── index.ts │ └── rewrite-url.middlewares.ts ├── dl-ws │ ├── dl-ws.module.ts │ └── dl-ws.gateway.ts ├── app.service.ts ├── api.controller.ts ├── file │ ├── file.module.ts │ ├── file.controller.ts │ └── file.service.ts ├── Custom │ └── custom-http.exception.ts ├── main.ts ├── app.module.ts └── app.controller.ts ├── tsconfig.build.json ├── nest-cli.json ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── tsconfig.json ├── .gitignore ├── .eslintrc.js ├── LICENSE ├── README.md ├── package.json └── views └── index.hbs /downloads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/repos/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export { default as RewriteUrlMiddleware } from './rewrite-url.middlewares'; -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "public"] 4 | } 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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"} -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | }); -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Charles Chrismann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /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). -------------------------------------------------------------------------------- /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/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 | } -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------