├── .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 |
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 |
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 |
23 |
24 |
29 |
30 |
31 |
32 |
38 |
39 |
40 |
41 | Already Cloned Repos
42 |
43 | {{#each users}}
44 | -
45 |
53 |
54 | {{#each this.repos}}
55 | - {{ this }}
56 | {{/each}}
57 |
58 |
59 | {{/each}}
60 |
61 |
62 |
63 |
64 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
--------------------------------------------------------------------------------