Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • add_route_assignments
  • ask-user-to-delete-exercises-on-duplicates
  • bedran_exercise-list
  • jw_sonar
  • jw_sonar_backup
  • main
  • update-dependencies
  • v6.0.0
  • 2.0.0
  • 2.1.0
  • 2.2.0
  • 3.0.0
  • 3.0.1
  • 3.1.0
  • 3.1.1
  • 3.1.2
  • 3.1.3
  • 3.2.0
  • 3.3.0
  • 3.4.0
  • 3.4.1
  • 3.4.2
  • 3.5.0
  • 3.5.1
  • 3.5.2
  • 3.5.3
  • 4.0.0
  • 4.1.0
  • 5.0.0
  • 5.0.1
  • 6.0.0-dev
  • v1.0.1
32 results

Target

Select target project
No results found
Select Git revision
  • add_route_assignments
  • ask-user-to-delete-exercises-on-duplicates
  • bedran_exercise-list
  • jw_sonar
  • jw_sonar_backup
  • main
  • update-dependencies
  • v6.0.0
  • 2.0.0
  • 2.1.0
  • 2.2.0
  • 3.0.0
  • 3.0.1
  • 3.1.0
  • 3.1.1
  • 3.1.2
  • 3.1.3
  • 3.2.0
  • 3.3.0
  • 3.4.0
  • 3.4.1
  • 3.4.2
  • 3.5.0
  • 3.5.1
  • 3.5.2
  • 3.5.3
  • 4.0.0
  • 4.1.0
  • 5.0.0
  • 5.0.1
  • 6.0.0-dev
  • v1.0.1
32 results
Show changes

Commits on Source 50

57 files
+ 3421
3061
Compare changes
  • Side-by-side
  • Inline

Files

+2 −0
Original line number Diff line number Diff line
@@ -7,6 +7,8 @@ ExpressAPI/src/config/Version.ts
redoc.html
OpenAPI.yaml-r

sonarlint.xml
sonarlint/

############################ MacOS
# General
+47 −12
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@ variables:
    WIKI_FOLDER: Wiki
        


.get_version:
    script:
        - IS_DEV=$([[ $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH ]] && echo false || echo true)
@@ -72,6 +73,36 @@ code_quality:lint:

        - npm install
        - npm run lint
    rules:
        -   if: $CI_COMMIT_TAG
            when: never
        -   if: $CI_PIPELINE_SOURCE == "merge_request_event"
            when: manual
        -   when: on_success


code_quality:sonarqube:
    stage: code_quality
    tags:
        - code_quality
    image:
        name: leadrien/isc-sonar-scanner-cli
        entrypoint: [ "" ]
    variables:
        SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar" # Defines the location of the analysis task cache
        GIT_DEPTH: "0" # Tells git to fetch all the branches of the project, required by the analysis task
    cache:
        key: "${CI_JOB_NAME}"
        paths:
            - .sonar/cache
    script:
        - sonar-scanner
    rules:
        -   if: $CI_COMMIT_TAG
            when: never
        -   if: $CI_PIPELINE_SOURCE == "merge_request_event"
            when: manual
        -   when: on_success


test:build:
@@ -84,7 +115,11 @@ test:build:
        - npm install
        - npm run build
    rules:
        - if: '$CI_COMMIT_TAG =~ "/^$/"'
        -   if: $CI_COMMIT_TAG
            when: never
        -   if: $CI_PIPELINE_SOURCE == "merge_request_event"
            when: manual
        -   when: on_success


clean:release:
@@ -96,7 +131,7 @@ clean:release:
        - !reference [.get_version, script]
        - !reference [.clean_release, script]
    rules:
        - if: '$CI_COMMIT_REF_PROTECTED == "true"'
        - if: $CI_COMMIT_REF_PROTECTED == "true"


clean:packages:
@@ -108,10 +143,10 @@ clean:packages:
        - !reference [.get_version, script]
        - !reference [.clean_packages, script]
    rules:
        - if: '$CI_COMMIT_REF_PROTECTED == "true"'
        - if: $CI_COMMIT_REF_PROTECTED == "true"


clean:dev:release:
clean:release:dev:
    stage: clean
    tags:
        - gitlab_clean
@@ -121,10 +156,10 @@ clean:dev:release:
        - VERSION="${VERSION}${VERSION_DEV_SUFFIX}"
        - !reference [.clean_release, script]
    rules:
        - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH


clean:dev:packages:
clean:packages:dev:
    stage: clean
    tags:
        - gitlab_clean
@@ -134,10 +169,10 @@ clean:dev:packages:
        - VERSION="${VERSION}${VERSION_DEV_SUFFIX}"
        - !reference [.clean_packages, script]
    rules:
        - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH


upload:packages:wiki:
upload:packages:doc:wiki:
    stage: upload
    tags:
        - gitlab_package
@@ -157,10 +192,10 @@ upload:packages:wiki:
        # Send package
        - 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file ${WIKI_ARCHIVE_PATH} "${PACKAGE_URL_WIKI}";'
    rules:
        - if: '$CI_COMMIT_REF_PROTECTED == "true"'
        - if: $CI_COMMIT_REF_PROTECTED == "true"


release:wiki:
release:doc:wiki:
    stage: release
    tags:
        - release
@@ -205,7 +240,7 @@ release:wiki:
        # Push the change back to the master branch of the wiki
        - git push origin "HEAD:main"
    rules:
        - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH


release:gitlab:
@@ -245,4 +280,4 @@ release:gitlab:
              --header "JOB-TOKEN: $CI_JOB_TOKEN" \
              --request POST "${GITLAB_API_PROJECT_URL}/releases"
    rules:
        - if: '$CI_COMMIT_REF_PROTECTED == "true"'
        - if: $CI_COMMIT_REF_PROTECTED == "true"
+18 −0
Original line number Diff line number Diff line
@@ -18,6 +18,24 @@
-->


## 4.0.0 (???)

### ✨ Feature
- Add features related to corrige (commentary, commit specific link / update, delete link)

### 🔨 Internal / Developers
- Migration to GitBreaker library for all Gitlab API calls
- SonarQube integration
- Multi-process start is disabled where it is not in a production environment

### 🐛 Bugfix
- Fix no response when Authorization header is missing
- Fix get assignment by url with assignment containing spaces

### 📚 Documentation
- Corrige routes documentation


## 3.5.3 (2024-02-26)

### 🐛 Bugfix

File changed.

Preview size limit exceeded, changes collapsed.

ExpressAPI/.eslintignore

deleted100644 → 0
+0 −4
Original line number Diff line number Diff line
dist
node_modules
logs
prisma
 No newline at end of file

ExpressAPI/.eslintrc.json

deleted100644 → 0
+0 −11
Original line number Diff line number Diff line
{
    "root"   : true,
    "parser" : "@typescript-eslint/parser",
    "plugins": [
        "@typescript-eslint"
    ],
    "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended"
    ]
}
 No newline at end of file
Original line number Diff line number Diff line
@@ -6,3 +6,5 @@
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# GitHub Copilot persisted chat sessions
/copilot/chatSessions
Original line number Diff line number Diff line
@@ -15,4 +15,7 @@
    <orderEntry type="inheritedJdk" />
    <orderEntry type="sourceFolder" forTests="false" />
  </component>
  <component name="SonarLintModuleSettings">
    <option name="uniqueId" value="2749ea0f-74a8-42c0-9fd6-d6a4b4cd75a4" />
  </component>
</module>
 No newline at end of file
Original line number Diff line number Diff line
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="EslintConfiguration">
    <custom-configuration-file used="false" path="eslint.config.mjs" />
    <option name="fix-on-save" value="true" />
  </component>
</project>
 No newline at end of file
Original line number Diff line number Diff line
openapi: 3.1.0
info:
    title: Dojo API
    version: 3.5.3
    version: 4.0.0
    description: |
        **Backend API of the Dojo project.**
        
+24 −0
Original line number Diff line number Diff line
// @ts-check
// @formatter:off

import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config({
                                   ignores: [ 'dist/*', 'node_modules/*', 'logs/*', 'prisma/*', 'eslint.config.mjs' ]
                               }, eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, {
                                   languageOptions: {
                                       parserOptions: {
                                           project: true, tsconfigRootDir: import.meta.dirname
                                       }
                                   }
                               }, {
                                   plugins: {
                                       '@typescript-eslint': tseslint.plugin
                                   }, rules: {
                                       '@typescript-eslint/no-unsafe-assignment': 'off',
                                       '@typescript-eslint/no-unsafe-member-access': 'off',
                                       '@typescript-eslint/require-await': 'off',
                                       '@typescript-eslint/restrict-template-expressions': 'off',
                                   }
                               });
 No newline at end of file
Original line number Diff line number Diff line
@@ -9,5 +9,5 @@
    "verbose": true,
    "ext"    : ".ts,.js",
    "ignore" : [],
    "exec"   : "npm run lint; npm run build:openapi; ts-node --files ./src/app.ts"
    "exec"   : "npm run lint; npm run build:openapi; tsc --noEmit && npx tsx src/app.ts"
}
Original line number Diff line number Diff line
{
    "name"           : "dojo_backend_api",
    "description"    : "Backend API of the Dojo project",
    "version"        : "3.5.3",
    "version"        : "4.0.0",
    "license"        : "AGPLv3",
    "author"         : "Michaël Minelli <dojo@minelli.me>",
    "main"           : "dist/src/app.js",
    "scripts"        : {
        "clean"                  : "rm -R dist/*",
        "dotenv:build"           : "npx dotenv-vault local build",
        "dotenv:build"           : "npx dotenvx encrypt",
        "lint"                   : "npx eslint .",
        "genversion"             : "npx genversion -s -e src/config/Version.ts",
        "build:openapi"          : "sed -i -r \"1,20 s/^\\([ ]*version:\\).*$/\\1 $(jq -r .version package.json)/\" assets/OpenAPI/OpenAPI.yaml; npx @redocly/cli build-docs assets/OpenAPI/OpenAPI.yaml --output=assets/OpenAPI/redoc.html",
@@ -28,14 +28,13 @@
        "seed": "node dist/prisma/seed"
    },
    "dependencies"   : {
        "@gitbeaker/rest"     : "^39.34.2",
        "@prisma/client"      : "^5.9.1",
        "axios"               : "^1.6.7",
        "@dotenvx/dotenvx"    : "^0.27.1",
        "@gitbeaker/rest"     : "^40.0.2",
        "@prisma/client"      : "^5.11.0",
        "axios"               : "^1.6.8",
        "compression"         : "^1.7.4",
        "cors"                : "^2.8.5",
        "dotenv"              : "^16.4.1",
        "dotenv-expand"       : "^10.0.0",
        "express"             : "^4.18.2",
        "express"             : "^4.19.2",
        "express-validator"   : "^7.0.1",
        "form-data"           : "^4.0.0",
        "helmet"              : "^7.1.0",
@@ -51,33 +50,31 @@
        "swagger-ui-express"  : "^5.0.0",
        "tar-stream"          : "^3.1.7",
        "uuid"                : "^9.0.1",
        "winston"             : "^3.11.0",
        "winston"             : "^3.13.0",
        "zod"                 : "^3.22.4",
        "zod-validation-error": "^3.0.0"
        "zod-validation-error": "^3.0.3"
    },
    "devDependencies": {
        "@redocly/cli"                    : "^1.8.2",
        "@redocly/cli"             : "^1.10.6",
        "@types/compression"       : "^1.7.5",
        "@types/cors"              : "^2.8.17",
        "@types/express"           : "^4.17.21",
        "@types/jsonwebtoken"             : "^9.0.5",
        "@types/jsonwebtoken"      : "^9.0.6",
        "@types/morgan"            : "^1.9.9",
        "@types/multer"            : "^1.4.11",
        "@types/node"                     : "^20.11.17",
        "@types/node"              : "^20.11.30",
        "@types/parse-link-header" : "^2.0.3",
        "@types/semver"                   : "^7.5.6",
        "@types/semver"            : "^7.5.8",
        "@types/swagger-ui-express": "^4.1.6",
        "@types/tar-stream"        : "^3.1.3",
        "@types/uuid"              : "^9.0.8",
        "@typescript-eslint/eslint-plugin": "^6.21.0",
        "@typescript-eslint/parser"       : "^6.21.0",
        "dotenv-cli"                      : "^7.3.0",
        "dotenv-vault"                    : "^1.26.0",
        "eslint"                   : "^8.57.0",
        "genversion"               : "^3.2.0",
        "nodemon"                         : "^3.0.3",
        "npm"                             : "^10.4.0",
        "prisma"                          : "^5.9.1",
        "ts-node"                         : "^10.9.2",
        "typescript"                      : "^5.3.3"
        "nodemon"                  : "^3.1.0",
        "npm"                      : "^10.5.0",
        "prisma"                   : "^5.11.0",
        "tsx"                      : "^4.7.1",
        "typescript"               : "^5.4.3",
        "typescript-eslint"        : "^7.4.0"
    }
}
Original line number Diff line number Diff line
require('../src/InitialImports'); // ATTENTION : These lines MUST be the first of this file
// ATTENTION : This line MUST be the first of this file
import '../src/init.js';

import * as process from 'process';
import SharedConfig from '../src/shared/config/SharedConfig';
import SharedConfig from '../src/shared/config/SharedConfig.js';
import { UserRole } from '@prisma/client';
import logger       from '../src/shared/logging/WinstonLogger';
import db           from '../src/helpers/DatabaseHelper';
import logger       from '../src/shared/logging/WinstonLogger.js';
import db           from '../src/helpers/DatabaseHelper.js';


async function main() {
@@ -16,8 +17,8 @@ async function main() {

main().then(async () => {
    await db.$disconnect();
}).catch(async (e) => {
    logger.error(e);
}).catch(async e => {
    logger.error(JSON.stringify(e));
    await db.$disconnect();
    process.exit(1);
});

ExpressAPI/src/InitialImports.ts

deleted100644 → 0
+0 −17
Original line number Diff line number Diff line
import path    from 'node:path';
import cluster from 'node:cluster';
import myEnv = require('dotenv');
import dotenvExpand = require('dotenv-expand');


if ( cluster.isPrimary ) {
    if ( process.env.NODE_ENV && process.env.NODE_ENV === 'production' ) {
        dotenvExpand.expand(myEnv.config());
    } else {
        myEnv.config({ path: path.join(__dirname, '../.env.keys') });
        dotenvExpand.expand(myEnv.config({ DOTENV_KEY: process.env.DOTENV_KEY_DEVELOPMENT }));
    }
}


require('./shared/helpers/TypeScriptExtensions'); // ATTENTION : This line MUST be after the dotenv.config() calls
Original line number Diff line number Diff line
require('./InitialImports'); // ATTENTION : These lines MUST be the first of this file
// ATTENTION : This line MUST be the first of this file
import './init.js';

import WorkerRole     from './process/WorkerRole';
import ClusterManager from './process/ClusterManager';
import API            from './express/API';
import HttpManager    from './managers/HttpManager';
import SharedConfig   from './shared/config/SharedConfig.js';
import WorkerRole     from './process/WorkerRole.js';
import ClusterManager from './process/ClusterManager.js';
import API            from './express/API.js';
import HttpManager    from './managers/HttpManager.js';


HttpManager.registerAxiosInterceptor();


if ( SharedConfig.production ) {
    (new ClusterManager([ {
        role         : WorkerRole.API,
        quantity     : ClusterManager.CORES,
        restartOnFail: true,
    loadTask     : () => {
        return new API();
    }
        loadTask     : () => new API()
    } ])).run();
} else {
    (new API()).run();
}
Original line number Diff line number Diff line
import GitlabVisibility from '../shared/types/Gitlab/GitlabVisibility';
import path             from 'path';
import fs               from 'fs';
import { Exercise }     from '../types/DatabaseTypes';
import { Exercise }     from '../types/DatabaseTypes.js';
import JSON5            from 'json5';
import GitlabVisibility from '../shared/types/Gitlab/GitlabVisibility.js';


type ConfigGitlabBadge = {
@@ -19,7 +19,7 @@ class Config {
        version: {
            [client: string]: string
        }
    }; // { version: { CLIENT: CONDITION } }
    };

    public readonly dojoCLI: {
        versionUpdatePeriodMs: number
@@ -52,13 +52,13 @@ class Config {

    public readonly assignment: {
        default: {
            description: string; initReadme: boolean; sharedRunnersEnabled: boolean; visibility: string; wikiEnabled: boolean; template: string
            description: string; initReadme: boolean; sharedRunnersEnabled: boolean; visibility: GitlabVisibility; wikiEnabled: boolean; template: string
        }; baseFiles: Array<string>; filename: string
    };

    public readonly exercise: {
        maxSameName: number; maxPerAssignment: number; resultsFolder: string, pipelineResultsFolder: string; default: {
            description: string; visibility: string;
            description: string; visibility: GitlabVisibility;
        };
    };

@@ -116,7 +116,7 @@ class Config {
                description         : process.env.ASSIGNMENT_DEFAULT_DESCRIPTION?.convertWithEnvVars() ?? '',
                initReadme          : process.env.ASSIGNMENT_DEFAULT_INIT_README?.toBoolean() ?? false,
                sharedRunnersEnabled: process.env.ASSIGNMENT_DEFAULT_SHARED_RUNNERS_ENABLED?.toBoolean() ?? true,
                visibility          : process.env.ASSIGNMENT_DEFAULT_VISIBILITY || GitlabVisibility.PRIVATE,
                visibility          : process.env.ASSIGNMENT_DEFAULT_VISIBILITY as GitlabVisibility || 'private',
                wikiEnabled         : process.env.ASSIGNMENT_DEFAULT_WIKI_ENABLED?.toBoolean() ?? false,
                template            : process.env.ASSIGNMENT_DEFAULT_TEMPLATE?.replace('{{USERNAME}}', this.gitlab.account.username).replace('{{TOKEN}}', this.gitlab.account.token) ?? ''
            },
@@ -131,7 +131,7 @@ class Config {
            pipelineResultsFolder: process.env.EXERCISE_PIPELINE_RESULTS_FOLDER ?? '', //Do not use convertWithEnvVars() because it is used in the exercise creation and muste be interpreted at exercise runtime
            default              : {
                description: process.env.EXERCISE_DEFAULT_DESCRIPTION?.convertWithEnvVars() ?? '',
                visibility : process.env.EXERCISE_DEFAULT_VISIBILITY || GitlabVisibility.PRIVATE
                visibility : process.env.EXERCISE_DEFAULT_VISIBILITY as GitlabVisibility || 'private'
            }
        };
    }
Original line number Diff line number Diff line
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import * as jwt                         from 'jsonwebtoken';
import { JwtPayload }                   from 'jsonwebtoken';
import Config                           from '../config/Config';
import Config                           from '../config/Config.js';
import express                          from 'express';
import UserManager                      from '../managers/UserManager';
import { User }                         from '../types/DatabaseTypes';
import DojoBackendResponse              from '../shared/types/Dojo/DojoBackendResponse';
import UserManager                      from '../managers/UserManager.js';
import { User }                         from '../types/DatabaseTypes.js';
import DojoBackendResponse              from '../shared/types/Dojo/DojoBackendResponse.js';


class Session {
@@ -19,12 +19,9 @@ class Session {
        this._profile = newProfile;
    }

    constructor() { }

    async initSession(req: express.Request, res: express.Response) {
        const authorization = req.headers.authorization;
        if ( authorization ) {
            if ( authorization.startsWith('Bearer ') ) {
        if ( authorization && authorization.startsWith('Bearer ') ) {
            const jwtToken = authorization.replace('Bearer ', '');

            try {
@@ -32,17 +29,17 @@ class Session {

                if ( jwtData.profile ) {
                    this.profile = jwtData.profile;
                        this.profile = await UserManager.getById(this.profile.id!) ?? this.profile;
                    this.profile = await UserManager.getById(this.profile.id) ?? this.profile;
                }
            } catch ( err ) {
                res.sendStatus(StatusCodes.UNAUTHORIZED).end();
            }
        }
    }
    }

    private static getToken(profileJson: unknown): string | null {
        return profileJson === null ? null : jwt.sign({ profile: profileJson }, Config.jwtConfig.secret, Config.jwtConfig.expiresIn > 0 ? { expiresIn: Config.jwtConfig.expiresIn } : {});
        const options = Config.jwtConfig.expiresIn > 0 ? { expiresIn: Config.jwtConfig.expiresIn } : {};
        return profileJson === null ? null : jwt.sign({ profile: profileJson }, Config.jwtConfig.secret, options);
    }

    private async getResponse<T>(code: number, data: T, descriptionOverride?: string): Promise<DojoBackendResponse<T>> {
@@ -67,14 +64,16 @@ class Session {
     Send a response to the client
     Information: Data could be a promise or an object. If it's a promise, we wait on the data to be resolved before sending the response
     */
    sendResponse(res: express.Response, code: number, data?: unknown, descriptionOverride?: string, internalCode?: number) {
        Promise.resolve(data).then((toReturn: unknown) => {
            this.getResponse(internalCode ?? code, toReturn, descriptionOverride).then(response => {
    sendResponse(res: express.Response | undefined, code: number, data?: unknown, descriptionOverride?: string, internalCode?: number) {
        if ( res ) {
            void Promise.resolve(data).then((toReturn: unknown) => {
                void this.getResponse(internalCode ?? code, toReturn, descriptionOverride).then(response => {
                    res.status(code).json(response);
                });
            });
        }
    }
}


export default Session;
Original line number Diff line number Diff line
import { Express }                    from 'express-serve-static-core';
import cors                           from 'cors';
import morganMiddleware               from '../logging/MorganMiddleware';
import morganMiddleware               from '../logging/MorganMiddleware.js';
import { AddressInfo }                from 'net';
import http                           from 'http';
import helmet                         from 'helmet';
import express                        from 'express';
import WorkerTask                     from '../process/WorkerTask';
import WorkerTask                     from '../process/WorkerTask.js';
import multer                         from 'multer';
import SessionMiddleware              from '../middlewares/SessionMiddleware';
import Config                         from '../config/Config';
import logger                         from '../shared/logging/WinstonLogger';
import ParamsCallbackManager          from '../middlewares/ParamsCallbackManager';
import ApiRoutesManager               from '../routes/ApiRoutesManager';
import SessionMiddleware              from '../middlewares/SessionMiddleware.js';
import Config                         from '../config/Config.js';
import logger                         from '../shared/logging/WinstonLogger.js';
import ParamsCallbackManager          from '../middlewares/ParamsCallbackManager.js';
import ApiRoutesManager               from '../routes/ApiRoutesManager.js';
import compression                    from 'compression';
import ClientVersionCheckerMiddleware from '../middlewares/ClientVersionCheckerMiddleware';
import ClientVersionCheckerMiddleware from '../middlewares/ClientVersionCheckerMiddleware.js';
import swaggerUi                      from 'swagger-ui-express';
import path                           from 'path';
import DojoCliVersionHelper           from '../helpers/DojoCliVersionHelper';
import DojoCliVersionHelper           from '../helpers/DojoCliVersionHelper.js';


class API implements WorkerTask {
    private readonly backend: Express;
    private server: http.Server | undefined;
    private server!: http.Server;

    constructor() {
        this.backend = express();
@@ -45,10 +45,12 @@ class API implements WorkerTask {
        this.backend.use(cors()); //Allow CORS requests
        this.backend.use(compression()); //Compress responses

        this.backend.use(async (req, res, next) => {
            res.header('dojocli-latest-version', await DojoCliVersionHelper.getLatestVersion());
        this.backend.use((_req, res, next) => {
            void DojoCliVersionHelper.getLatestVersion().then((latestVersion) => {
                res.header('dojocli-latest-version', latestVersion);
                next();
            });
        });
    }

    private initOpenAPI() {
@@ -59,9 +61,9 @@ class API implements WorkerTask {
                url: '../OpenAPI.yaml'
            }
        };
        this.backend.get('/docs/OpenAPI.yaml', (req, res) => res.sendFile(path.resolve(__dirname + '/../../assets/OpenAPI/OpenAPI.yaml')));
        this.backend.get('/docs/OpenAPI.yaml', (_req, res) => res.sendFile(path.resolve(__dirname + '/../../assets/OpenAPI/OpenAPI.yaml')));
        this.backend.use('/docs/swagger', swaggerUi.serveFiles(undefined, options), swaggerUi.setup(undefined, options));
        this.backend.get('/docs/redoc.html', (req, res) => res.sendFile(path.resolve(__dirname + '/../../assets/OpenAPI/redoc.html')));
        this.backend.get('/docs/redoc.html', (_req, res) => res.sendFile(path.resolve(__dirname + '/../../assets/OpenAPI/redoc.html')));

        this.backend.get('/docs/', (req, res) => {
            const prefix = req.url.slice(-1) === '/' ? '' : 'docs/';
@@ -89,7 +91,7 @@ class API implements WorkerTask {
            const {
                      port,
                      address
                  } = this.server!.address() as AddressInfo;
                  } = this.server.address() as AddressInfo;
            logger.info(`Server started on http://${ address }:${ port }`);
        });
    }
Original line number Diff line number Diff line
import { PrismaClient }          from '@prisma/client';
import logger                    from '../shared/logging/WinstonLogger';
import UserQueryExtension        from './Prisma/Extensions/UserQueryExtension';
import UserResultExtension       from './Prisma/Extensions/UserResultExtension';
import AssignmentResultExtension from './Prisma/Extensions/AssignmentResultExtension';
import ExerciseResultExtension   from './Prisma/Extensions/ExerciseResultExtension';
import logger                    from '../shared/logging/WinstonLogger.js';
import UserQueryExtension        from './Prisma/Extensions/UserQueryExtension.js';
import UserResultExtension       from './Prisma/Extensions/UserResultExtension.js';
import AssignmentResultExtension from './Prisma/Extensions/AssignmentResultExtension.js';
import ExerciseResultExtension   from './Prisma/Extensions/ExerciseResultExtension.js';


const prisma = new PrismaClient({
@@ -31,7 +31,7 @@ prisma.$on('warn', e => logger.warn(`Prisma => ${ e.message }`));
prisma.$on('error', e => logger.error(`Prisma => ${ e.message }`));


const db = prisma.$extends(UserQueryExtension).$extends(UserResultExtension).$extends(AssignmentResultExtension).$extends(ExerciseResultExtension);
const DatabaseHelper = prisma.$extends(UserQueryExtension).$extends(UserResultExtension).$extends(AssignmentResultExtension).$extends(ExerciseResultExtension);


export default db;
 No newline at end of file
export default DatabaseHelper;
 No newline at end of file
Original line number Diff line number Diff line
import Config        from '../config/Config';
import GitlabRelease from '../shared/types/Gitlab/GitlabRelease';
import GitlabManager from '../managers/GitlabManager';
import Config        from '../config/Config.js';
import GitlabManager from '../managers/GitlabManager.js';
import * as Gitlab   from '@gitbeaker/rest';


class DojoCliVersionHelper {
    private latestUpdate: Date | undefined;
    private latestVersion: string | undefined;

    constructor() { }

    private async updateVersion(): Promise<void> {
        const releases: Array<GitlabRelease> = await GitlabManager.getRepositoryReleases(Config.dojoCLI.repositoryId);
        const releases: Array<Gitlab.ReleaseSchema> = await GitlabManager.getRepositoryReleases(Config.dojoCLI.repositoryId);
        for ( const release of releases ) {
            if ( !isNaN(+release.tag_name.replace('.', '')) ) {
                this.latestVersion = release.tag_name;
Original line number Diff line number Diff line
import LazyVal from '../shared/helpers/LazyVal';
import LazyVal from '../shared/helpers/LazyVal.js';


class DojoModelsHelper {
@@ -9,8 +9,7 @@ class DojoModelsHelper {
     * @param depth The depth of the search for LazyVal instances
     */
    async getFullSerializableObject<T extends NonNullable<unknown>>(obj: T, depth: number = 0): Promise<unknown> {
        /* eslint-disable-next-line  @typescript-eslint/no-explicit-any */
        const result: any = {};
        const result: { [key: string]: unknown } = {};

        for ( const key in obj ) {
            let value: unknown = obj[key];
Original line number Diff line number Diff line
import Config                                                       from '../config/Config';
import { StatusCodes }                                              from 'http-status-codes';
import Config                                                       from '../config/Config.js';
import { CustomValidator, ErrorMessage, FieldMessageFactory, Meta } from 'express-validator/src/base';
import { BailOptions, ValidationChain }                             from 'express-validator/src/chain';
import GitlabManager                                                from '../managers/GitlabManager';
import GitlabManager                                                from '../managers/GitlabManager.js';
import express                                                      from 'express';
import logger                                                       from '../shared/logging/WinstonLogger';
import Json5FileValidator                                           from '../shared/helpers/Json5FileValidator';
import ExerciseResultsFile                                          from '../shared/types/Dojo/ExerciseResultsFile';
import ParamsCallbackManager                                        from '../middlewares/ParamsCallbackManager';
import ExerciseManager                                              from '../managers/ExerciseManager';
import logger                                                       from '../shared/logging/WinstonLogger.js';
import Json5FileValidator                                           from '../shared/helpers/Json5FileValidator.js';
import ExerciseResultsFile                                          from '../shared/types/Dojo/ExerciseResultsFile.js';
import ParamsCallbackManager                                        from '../middlewares/ParamsCallbackManager.js';
import ExerciseManager                                              from '../managers/ExerciseManager.js';
import Toolbox                                                      from '../shared/helpers/Toolbox.js';


declare type DojoMeta = Meta & {
@@ -31,11 +31,11 @@ class DojoValidators {
    }

    readonly nullSanitizer = this.toValidatorSchemaOptions({
                                                               options: (value) => {
                                                               options: value => {
                                                                   try {
                                                                       return value == 'null' || value == 'undefined' || value == '' ? null : value;
                                                                       return value === 'null' || value === 'undefined' || value === '' ? null : value;
                                                                   } catch ( error ) {
                                                                       logger.error(`null sanitizer error: ${ error }`);
                                                                       logger.error(`null sanitizer error: ${ JSON.stringify(error) }`);

                                                                       return value;
                                                                   }
@@ -43,9 +43,9 @@ class DojoValidators {
                                                           });

    readonly jsonSanitizer = this.toValidatorSchemaOptions({
                                                               options: (value) => {
                                                               options: value => {
                                                                   try {
                                                                       return JSON.parse(value as string);
                                                                       return JSON.parse(value as string) as unknown;
                                                                   } catch ( e ) {
                                                                       return value;
                                                                   }
@@ -62,8 +62,8 @@ class DojoValidators {
                                                                          return new Promise((resolve, reject) => {
                                                                              const template = this.getParamValue(req, path) as string;
                                                                              if ( template ) {
                                                                                  GitlabManager.checkTemplateAccess(template, req).then((templateAccess) => {
                                                                                      templateAccess !== StatusCodes.OK ? reject() : resolve(true);
                                                                                  void GitlabManager.checkTemplateAccess(template, req).then(templateAccess => {
                                                                                      templateAccess ? resolve(true) : reject();
                                                                                  });
                                                                              }
                                                                              resolve(true);
@@ -78,13 +78,14 @@ class DojoValidators {
                                                                      }) => {
                                                                          try {
                                                                              const template = this.getParamValue(req, path);
                                                                              if ( template ) {
                                                                                  return `${ Config.gitlab.urls[0].replace(/^([a-z]{3,5}:\/{2})?(.*)/, `$1${ Config.gitlab.account.username }:${ Config.gitlab.account.token }@$2`) }${ template }.git`;
                                                                              if ( template && Toolbox.isString(template) ) {
                                                                                  const gitlabUrlWithCredentials = Config.gitlab.urls[0].replace(/^([a-z]{3,5}:\/{2})?(.*)/, `$1${ Config.gitlab.account.username }:${ Config.gitlab.account.token }@$2`);
                                                                                  return `${ gitlabUrlWithCredentials }${ template }.git`;
                                                                              } else {
                                                                                  return Config.assignment.default.template;
                                                                              }
                                                                          } catch ( error ) {
                                                                              logger.error(`Template url sanitizer error: ${ error }`);
                                                                              logger.error(`Template url sanitizer error: ${ JSON.stringify(error) }`);

                                                                              return value;
                                                                          }
@@ -121,7 +122,7 @@ class DojoValidators {
                                                                                  if ( exerciseIdOrUrl ) {
                                                                                      ParamsCallbackManager.initBoundParams(req);

                                                                                      ExerciseManager.get(exerciseIdOrUrl).then((exercise) => {
                                                                                      ExerciseManager.get(exerciseIdOrUrl).then(exercise => {
                                                                                          req.boundParams.exercise = exercise;

                                                                                          exercise !== undefined ? resolve(true) : reject();
Original line number Diff line number Diff line
import express                   from 'express';
import GitlabRepository from '../shared/types/Gitlab/GitlabRepository';
import logger           from '../shared/logging/WinstonLogger';
import GitlabManager    from '../managers/GitlabManager';
import { AxiosError }   from 'axios';
import logger                    from '../shared/logging/WinstonLogger.js';
import GitlabManager             from '../managers/GitlabManager.js';
import DojoStatusCode            from '../shared/types/Dojo/DojoStatusCode.js';
import { StatusCodes }           from 'http-status-codes';
import DojoStatusCode   from '../shared/types/Dojo/DojoStatusCode';
import { GitbeakerRequestError } from '@gitbeaker/requester-utils';
import * as Gitlab               from '@gitbeaker/rest';


class GlobalHelper {
    async repositoryCreationError(message: string, error: unknown, req: express.Request, res: express.Response, gitlabError: DojoStatusCode, internalError: DojoStatusCode, repositoryToRemove?: GitlabRepository): Promise<void> {
        logger.error(message);
        logger.error(error);
    repoCreationFnExecCreator(req: express.Request, res: express.Response, gitlabError: DojoStatusCode, internalError: DojoStatusCode, repositoryToRemove?: Gitlab.ProjectSchema) {
        return async (toExec: () => Promise<unknown>, errorMessage?: string) => {
            try {
                return await toExec();
            } catch ( error ) {
                if ( errorMessage ) {
                    logger.error(errorMessage);
                    logger.error(JSON.stringify(error));

                    try {
                        if ( repositoryToRemove ) {
                            await GitlabManager.deleteRepository(repositoryToRemove.id);
                        }
        } catch ( error ) {
                    } catch ( deleteError ) {
                        logger.error('Repository deletion error');
            logger.error(error);
                        logger.error(JSON.stringify(deleteError));
                    }

        if ( error instanceof AxiosError ) {
            return req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, {}, `Unknown gitlab error: ${ message }`, gitlabError);
                    if ( error instanceof GitbeakerRequestError ) {
                        req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, {}, `Unknown gitlab error: ${ errorMessage }`, gitlabError);
                        throw error;
                    }

        return req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, {}, `Unknown error: ${ message }`, internalError);
                    req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, {}, `Unknown error: ${ errorMessage }`, internalError);
                    throw error;
                }
            }

            return undefined;
        };
    }

    isRepoNameAlreadyTaken(errorDescription: unknown) {
        return errorDescription instanceof Object && 'name' in errorDescription && errorDescription.name instanceof Array && errorDescription.name.length > 0 && errorDescription.name[0] === 'has already been taken';
    }

    addRepoMember(repositoryId: number) {
        return async (memberId: number): Promise<Gitlab.MemberSchema | false> => {
            try {
                return await GitlabManager.addRepositoryMember(repositoryId, memberId, Gitlab.AccessLevel.DEVELOPER);
            } catch ( error ) {
                logger.error('Add member error');
                logger.error(JSON.stringify(error));
                return false;
            }
        };
    }
}

Original line number Diff line number Diff line
import { Prisma }   from '@prisma/client';
import { Exercise } from '../../../types/DatabaseTypes';
import db           from '../../DatabaseHelper';
import LazyVal      from '../../../shared/helpers/LazyVal';
import { Exercise } from '../../../types/DatabaseTypes.js';
import db           from '../../DatabaseHelper.js';
import LazyVal      from '../../../shared/helpers/LazyVal.js';


async function getCorrections(assignment: { name: string }): Promise<Array<Partial<Exercise>> | undefined> {
@@ -30,9 +30,7 @@ export default Prisma.defineExtension(client => {
                                   assignment: {
                                       corrections: {
                                           compute(assignment) {
                                               return new LazyVal<Array<Partial<Exercise>> | undefined>(() => {
                                                   return getCorrections(assignment);
                                               });
                                               return new LazyVal<Array<Partial<Exercise>> | undefined>(() => getCorrections(assignment));
                                           }
                                       }
                                   }
Original line number Diff line number Diff line
import { Prisma, UserRole } from '@prisma/client';
import LazyVal              from '../../../shared/helpers/LazyVal';
import GitlabUser           from '../../../shared/types/Gitlab/GitlabUser';
import GitlabManager        from '../../../managers/GitlabManager';
import LazyVal              from '../../../shared/helpers/LazyVal.js';
import * as Gitlab          from '@gitbeaker/rest';
import GitlabManager        from '../../../managers/GitlabManager.js';


export default Prisma.defineExtension(client => {
@@ -13,7 +13,7 @@ export default Prisma.defineExtension(client => {
                                               role: true
                                           },
                                           compute(user) {
                                               return user.role == UserRole.TEACHING_STAFF || user.role == UserRole.ADMIN;
                                               return user.role === UserRole.TEACHING_STAFF || user.role === UserRole.ADMIN;
                                           }
                                       },
                                       isAdmin        : {
@@ -21,14 +21,12 @@ export default Prisma.defineExtension(client => {
                                               role: true
                                           },
                                           compute(user) {
                                               return user.role == UserRole.ADMIN;
                                               return user.role === UserRole.ADMIN;
                                           }
                                       },
                                       gitlabProfile  : {
                                           compute(user) {
                                               return new LazyVal<GitlabUser | undefined>(() => {
                                                   return GitlabManager.getUserById(user.id);
                                               });
                                               return new LazyVal<Gitlab.UserSchema | undefined>(() => GitlabManager.getUserById(user.id));
                                           }
                                       }
                                   }

ExpressAPI/src/init.ts

0 → 100644
+15 −0
Original line number Diff line number Diff line
import path         from 'node:path';
import cluster      from 'node:cluster';
import dotenv       from 'dotenv';
import dotenvExpand from 'dotenv-expand';
import './shared/helpers/TypeScriptExtensions.js';


if ( cluster.isPrimary ) {
    if ( process.env.NODE_ENV && process.env.NODE_ENV === 'production' ) {
        dotenvExpand.expand(dotenv.config());
    } else {
        dotenv.config({ path: path.join(__dirname, '../.env.keys') });
        dotenvExpand.expand(dotenv.config({ DOTENV_KEY: process.env.DOTENV_KEY_DEVELOPMENT }));
    }
}
Original line number Diff line number Diff line
import morgan, { StreamOptions } from 'morgan';
import logger                    from '../shared/logging/WinstonLogger';
import logger                    from '../shared/logging/WinstonLogger.js';


const stream: StreamOptions = {
    write: (message) => logger.http(message)
    write: message => logger.http(message)
};

const skip = () => {
    return false; //SharedConfig.production; 
};
const skip = () => false;

const morganMiddleware = morgan(':method :url :status :res[content-length] - :response-time ms', {
    stream,
Original line number Diff line number Diff line
import { Prisma }           from '@prisma/client';
import { Assignment, User } from '../types/DatabaseTypes';
import db                   from '../helpers/DatabaseHelper';
import { Assignment, User } from '../types/DatabaseTypes.js';
import db                   from '../helpers/DatabaseHelper.js';


class AssignmentManager {
@@ -19,19 +19,32 @@ class AssignmentManager {
        return await db.assignment.findUnique({
                                                  where  : {
                                                      name: name
                                                  }, include: include
                                                  },
                                                  include: include
                                              }) as unknown as Assignment ?? undefined;
    }

    getByGitlabLink(gitlabLink: string, include: Prisma.AssignmentInclude | undefined = undefined): Promise<Assignment | undefined> {
        const name = gitlabLink.replace('.git', '').split('/').pop()!;
    async getByGitlabLink(gitlabLink: string, include: Prisma.AssignmentInclude | undefined = undefined): Promise<Assignment | undefined> {
        const nameInUrl = gitlabLink.replace('.git', '').split('/').pop()!;

        return this.getByName(name, include);
        const result = await db.assignment.findMany({
                                                        where  : {
                                                            gitlabLink: {
                                                                endsWith: `/${ nameInUrl }`
                                                            }
                                                        },
                                                        include: include
                                                    }) as Array<Assignment>;

        return result.length > 0 ? result[0] : undefined;
    }

    get(nameOrUrl: string, include: Prisma.AssignmentInclude | undefined = undefined): Promise<Assignment | undefined> {
        // We can use the same function for both name and url because the name is the last part of the url and the name extraction from the url doesn't corrupt the name
        if ( nameOrUrl.includes('://') ) {
            return this.getByGitlabLink(nameOrUrl, include);
        } else {
            return this.getByName(nameOrUrl, include);
        }
    }
}

Original line number Diff line number Diff line
import { Prisma }   from '@prisma/client';
import { Exercise } from '../types/DatabaseTypes';
import db           from '../helpers/DatabaseHelper';
import { Exercise } from '../types/DatabaseTypes.js';
import db           from '../helpers/DatabaseHelper.js';


class ExerciseManager {
@@ -15,13 +15,13 @@ class ExerciseManager {
                                            }) as unknown as Exercise ?? undefined;
    }

    async getFromAssignment(assignmentName: string, include: Prisma.ExerciseInclude | undefined = undefined): Promise<Array<Exercise> | undefined> {
        return await db.exercise.findMany({
    getFromAssignment(assignmentName: string, include: Prisma.ExerciseInclude | undefined = undefined): Promise<Array<Exercise>> {
        return db.exercise.findMany({
                                        where  : {
                                            assignmentName: assignmentName
                                        },
                                        include: include
                                          }) as Array<Exercise> ?? undefined;
                                    }) as Promise<Array<Exercise>>;
    }
}

Original line number Diff line number Diff line
import axios, { AxiosError, AxiosRequestHeaders } from 'axios';
import Config                                     from '../config/Config';
import FormData                                   from 'form-data';
import logger                                     from '../shared/logging/WinstonLogger';
import SharedConfig                               from '../shared/config/SharedConfig';
import logger                                     from '../shared/logging/WinstonLogger.js';


class HttpManager {
@@ -12,33 +10,16 @@ class HttpManager {
    }

    private registerRequestInterceptor() {
        axios.interceptors.request.use((config) => {
        axios.interceptors.request.use(config => {
            if ( config.data instanceof FormData ) {
                config.headers = { ...config.headers, ...(config.data as FormData).getHeaders() } as AxiosRequestHeaders;
                config.headers = { ...config.headers, ...config.data.getHeaders() } as AxiosRequestHeaders;
            }

            if ( config.url && config.url.indexOf(SharedConfig.gitlab.apiURL) !== -1 ) {
                if ( !config.headers.DojoOverrideAuthorization ) {
                    config.headers['PRIVATE-TOKEN'] = Config.gitlab.account.token;
                }
            }

            if ( config.headers.DojoOverrideAuthorization && 'DojoAuthorizationHeader' in config.headers && 'DojoAuthorizationValue' in config.headers ) {
                config.headers[config.headers.DojoAuthorizationHeader] = config.headers.DojoAuthorizationValue;

                delete config.headers.DojoOverrideAuthorization;
                delete config.headers.DojoAuthorizationHeader;
                delete config.headers.DojoAuthorizationValue;
            }

            return config;
        });
    }

    private registerResponseInterceptor() {
        axios.interceptors.response.use((response) => {
            return response;
        }, (error) => {
        axios.interceptors.response.use(response => response, error => {
            if ( error instanceof AxiosError ) {
                logger.error(`${ JSON.stringify(error.response?.data) }`);
            } else {
Original line number Diff line number Diff line
import GitlabUser    from '../shared/types/Gitlab/GitlabUser';
import { Prisma }  from '@prisma/client';
import db            from '../helpers/DatabaseHelper';
import GitlabProfile from '../shared/types/Gitlab/GitlabProfile';
import { User }      from '../types/DatabaseTypes';
import db          from '../helpers/DatabaseHelper.js';
import { User }    from '../types/DatabaseTypes.js';
import * as Gitlab from '@gitbeaker/rest';


class UserManager {
@@ -24,7 +23,7 @@ class UserManager {
                                        }) as unknown as User ?? undefined;
    }

    async getUpdateFromGitlabProfile(gitlabProfile: GitlabProfile): Promise<User> {
    async getUpdateFromGitlabProfile(gitlabProfile: Gitlab.ExpandedUserSchema): Promise<User> {
        await db.user.upsert({
                                 where : {
                                     id: gitlabProfile.id
@@ -46,7 +45,7 @@ class UserManager {
        return (await this.getById(gitlabProfile.id))!;
    }

    async getFromGitlabUser(gitlabUser: GitlabUser, createIfNotExist: boolean = false, include: Prisma.UserInclude | undefined = undefined): Promise<User | number | undefined> {
    async getFromGitlabUser(gitlabUser: Gitlab.UserSchema, createIfNotExist: boolean = false, include: Prisma.UserInclude | undefined = undefined): Promise<User | number | undefined> {
        let user = await this.getById(gitlabUser.id, include) ?? gitlabUser.id;

        if ( typeof user === 'number' && createIfNotExist ) {
@@ -61,7 +60,7 @@ class UserManager {
        return user;
    }

    async getFromGitlabUsers(gitlabUsers: Array<GitlabUser>, createIfNotExist: boolean = false, include: Prisma.UserInclude | undefined = undefined): Promise<Array<User | number | undefined>> {
    async getFromGitlabUsers(gitlabUsers: Array<Gitlab.UserSchema>, createIfNotExist: boolean = false, include: Prisma.UserInclude | undefined = undefined): Promise<Array<User | number | undefined>> {
        return Promise.all(gitlabUsers.map(gitlabUser => this.getFromGitlabUser(gitlabUser, createIfNotExist, include)));
    }
}
Original line number Diff line number Diff line
import express         from 'express';
import Config             from '../config/Config';
import Config          from '../config/Config.js';
import semver          from 'semver/preload';
import Session            from '../controllers/Session';
import { HttpStatusCode } from 'axios';
import DojoStatusCode     from '../shared/types/Dojo/DojoStatusCode';
import Session         from '../controllers/Session.js';
import DojoStatusCode  from '../shared/types/Dojo/DojoStatusCode.js';
import { StatusCodes } from 'http-status-codes';


class ClientVersionCheckerMiddleware {
    register(): (req: express.Request, res: express.Response, next: express.NextFunction) => void {
        return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
        return (req: express.Request, res: express.Response, next: express.NextFunction) => {
            if ( req.headers['client'] && req.headers['client-version'] ) {
                const requestClient = req.headers['client'] as string;
                const requestClientVersion = req.headers['client-version'] as string;
@@ -19,13 +19,15 @@ class ClientVersionCheckerMiddleware {
                            next();
                            return;
                        } else {
                            new Session().sendResponse(res, HttpStatusCode.MethodNotAllowed, {}, `Client version ${ requestClientVersion } is not supported. Please update your client.`, DojoStatusCode.CLIENT_VERSION_NOT_SUPPORTED);
                            new Session().sendResponse(res, StatusCodes.METHOD_NOT_ALLOWED, {}, `Client version ${ requestClientVersion } is not supported. Please update your client.`, DojoStatusCode.CLIENT_VERSION_NOT_SUPPORTED);
                            return;
                        }
                    }
                }

                new Session().sendResponse(res, HttpStatusCode.MethodNotAllowed, {}, `Unsupported client.`, DojoStatusCode.CLIENT_NOT_SUPPORTED);
                new Session().sendResponse(res, StatusCodes.METHOD_NOT_ALLOWED, {}, `Unsupported client.`, DojoStatusCode.CLIENT_NOT_SUPPORTED);
            } else {
                next();
            }
        };
    }
Original line number Diff line number Diff line
import { Express }       from 'express-serve-static-core';
import express           from 'express';
import { StatusCodes }   from 'http-status-codes';
import ExerciseManager   from '../managers/ExerciseManager';
import AssignmentManager from '../managers/AssignmentManager';
import ExerciseManager   from '../managers/ExerciseManager.js';
import AssignmentManager from '../managers/AssignmentManager.js';


type GetFunction = (id: string | number, ...args: Array<unknown>) => Promise<unknown>
@@ -11,7 +11,7 @@ type GetFunction = (id: string | number, ...args: Array<unknown>) => Promise<unk
class ParamsCallbackManager {
    protected listenParam(paramName: string, backend: Express, getFunction: GetFunction, args: Array<unknown>, indexName: string) {
        backend.param(paramName, (req: express.Request, res: express.Response, next: express.NextFunction, id: string | number) => {
            getFunction(id, ...args).then(result => {
            void getFunction(id, ...args).then(result => {
                if ( result ) {
                    this.initBoundParams(req);
                    (req.boundParams as Record<string, unknown>)[indexName] = result;
Original line number Diff line number Diff line
@@ -5,20 +5,20 @@ import { StatusCodes } from 'http-status-codes';

class ParamsValidatorMiddleware {
    validate(validations: Array<ExpressValidator.ValidationChain> | ExpressValidator.Schema): (req: express.Request, res: express.Response, next: express.NextFunction) => void {
        return async (req: express.Request, res: express.Response, next: express.NextFunction) => {

        return (req: express.Request, res: express.Response, next: express.NextFunction) => {
            if ( !(validations instanceof Array) ) {
                validations = ExpressValidator.checkSchema(validations);
            }

            await Promise.all(validations.map(validation => validation.run(req)));

            void Promise.all(validations.map(validation => validation.run(req))).then(() => {
                const errors = ExpressValidator.validationResult(req);
                if ( !errors.isEmpty() ) {
                return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, { errors: errors.array() });
                    req.session.sendResponse(res, StatusCodes.BAD_REQUEST, { errors: errors.array() });
                    return;
                }

            return next();
                next();
            });
        };
    }
}
Original line number Diff line number Diff line
import express           from 'express';
import { StatusCodes }   from 'http-status-codes';
import SecurityCheckType from '../types/SecurityCheckType';
import logger            from '../shared/logging/WinstonLogger';
import AssignmentManager from '../managers/AssignmentManager';
import SecurityCheckType from '../types/SecurityCheckType.js';
import logger            from '../shared/logging/WinstonLogger.js';
import AssignmentManager from '../managers/AssignmentManager.js';


class SecurityMiddleware {
    // First check if connected then check if at least ONE rule match. It's NOT an AND but it's a OR function.
    check(checkIfConnected: boolean, ...checkTypes: Array<SecurityCheckType>): (req: express.Request, res: express.Response, next: express.NextFunction) => void {
        return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
            if ( checkIfConnected ) {
                if ( req.session.profile === null || req.session.profile === undefined ) {
                    return req.session.sendResponse(res, StatusCodes.UNAUTHORIZED);
    private isConnected(checkIfConnected: boolean, req: express.Request): boolean {
        return checkIfConnected && req.session.profile !== null && req.session.profile !== undefined;
    }
            }

            let isAllowed = checkTypes.length === 0;

            if ( !isAllowed ) {
                for ( const checkType of checkTypes ) {
    private async checkType(checkType: SecurityCheckType, req: express.Request): Promise<boolean> {
        try {
            switch ( String(checkType) ) {
                            case SecurityCheckType.TEACHING_STAFF:
                                isAllowed = isAllowed || req.session.profile.isTeachingStaff;
                                break;
                            case SecurityCheckType.ASSIGNMENT_STAFF:
                                isAllowed = isAllowed || await AssignmentManager.isUserAllowedToAccessAssignment(req.boundParams.assignment!, req.session.profile);
                                break;
                            case SecurityCheckType.ASSIGNMENT_IS_PUBLISHED:
                                isAllowed = isAllowed || (req.boundParams.assignment?.published ?? false);
                                break;
                            case SecurityCheckType.EXERCISE_SECRET:
                                isAllowed = isAllowed || (req.headers.exercisesecret as string | undefined) === req.boundParams.exercise!.secret;
                                break;
                case SecurityCheckType.TEACHING_STAFF.valueOf():
                    return req.session.profile.isTeachingStaff;
                case SecurityCheckType.ASSIGNMENT_STAFF.valueOf():
                    return await AssignmentManager.isUserAllowedToAccessAssignment(req.boundParams.assignment!, req.session.profile);
                case SecurityCheckType.ASSIGNMENT_IS_PUBLISHED.valueOf():
                    return req.boundParams.assignment?.published ?? false;
                case SecurityCheckType.EXERCISE_SECRET.valueOf():
                    return (req.headers.exercisesecret as string | undefined) === req.boundParams.exercise!.secret;
                default:
                                break;
                    return false;
            }
        } catch ( e ) {
                        logger.error('Security check failed !!! => ' + e);
                        isAllowed = isAllowed || false;
            logger.error('Security check failed !!! => ' + JSON.stringify(e));
            return false;
        }
    }

    // First check if connected then check if at least ONE rule match. It's NOT an AND but it's a OR function.
    check(checkIfConnected: boolean, ...checkTypes: Array<SecurityCheckType>): (req: express.Request, res: express.Response, next: express.NextFunction) => void {
        return (req: express.Request, res: express.Response, next: express.NextFunction) => {
            if ( !this.isConnected(checkIfConnected, req) ) {
                return req.session.sendResponse(res, StatusCodes.UNAUTHORIZED);
            }

            const isAllowed: boolean = checkTypes.length === 0 ? true : checkTypes.find(async checkType => this.checkType(checkType, req)) !== undefined;

            if ( !isAllowed ) {
                return req.session.sendResponse(res, StatusCodes.FORBIDDEN);
            }
Original line number Diff line number Diff line
import express     from 'express';
import Session     from '../controllers/Session';
import Session     from '../controllers/Session.js';
import { Express } from 'express-serve-static-core';


class SessionMiddleware {
    registerOnBackend(backend: Express) {
        backend.use(async (req: express.Request, res: express.Response, next: express.NextFunction) => {
        backend.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
            req.session = new Session();
            await req.session.initSession(req, res);

            return next();
            void req.session.initSession(req, res).then(() => {
                next();
            });
        });
    }
}
Original line number Diff line number Diff line
import cluster, { Worker } from 'node:cluster';
import WorkerRole          from './WorkerRole';
import WorkerRole          from './WorkerRole.js';
import os                  from 'os';
import ClusterStrategy     from './ClusterStrategy';
import WorkerPool          from './WorkerPool';
import logger              from '../shared/logging/WinstonLogger';
import ClusterStrategy     from './ClusterStrategy.js';
import WorkerPool          from './WorkerPool.js';
import logger              from '../shared/logging/WinstonLogger.js';


/*
@@ -11,10 +11,13 @@ import logger from '../shared/logging/WinstonLogger';
 */
class ClusterManager {
    public static readonly CORES = os.cpus().length;
    private readonly strategy: ClusterStrategy;

    private workers: { [pid: number]: WorkerRole; } = [];

    constructor(private strategy: ClusterStrategy) {}
    constructor(strategy: ClusterStrategy) {
        this.strategy = strategy;
    }

    private getWorkerPool(role: WorkerRole): WorkerPool | undefined {
        return this.strategy.find(elem => elem.role === role);
Original line number Diff line number Diff line
import WorkerPool from './WorkerPool';
import WorkerPool from './WorkerPool.js';


type ClusterStrategy = Array<WorkerPool>
Original line number Diff line number Diff line
import WorkerRole from './WorkerRole';
import WorkerTask from './WorkerTask';
import WorkerRole from './WorkerRole.js';
import WorkerTask from './WorkerTask.js';


/*
Original line number Diff line number Diff line
import { Express }      from 'express-serve-static-core';
import RoutesManager    from '../express/RoutesManager';
import BaseRoutes       from './BaseRoutes';
import SessionRoutes    from './SessionRoutes';
import AssignmentRoutes from './AssignmentRoutes';
import GitlabRoutes     from './GitlabRoutes';
import ExerciseRoutes   from './ExerciseRoutes';
import RoutesManager    from '../express/RoutesManager.js';
import BaseRoutes       from './BaseRoutes.js';
import SessionRoutes    from './SessionRoutes.js';
import AssignmentRoutes from './AssignmentRoutes.js';
import GitlabRoutes     from './GitlabRoutes.js';
import ExerciseRoutes   from './ExerciseRoutes.js';


class AdminRoutesManager implements RoutesManager {
Original line number Diff line number Diff line
import { Express }                 from 'express-serve-static-core';
import express                        from 'express';
import express, { RequestHandler } from 'express';
import * as ExpressValidator       from 'express-validator';
import { StatusCodes }             from 'http-status-codes';
import RoutesManager                  from '../express/RoutesManager';
import ParamsValidatorMiddleware      from '../middlewares/ParamsValidatorMiddleware';
import SecurityMiddleware             from '../middlewares/SecurityMiddleware';
import SecurityCheckType              from '../types/SecurityCheckType';
import GitlabUser                     from '../shared/types/Gitlab/GitlabUser';
import GitlabManager                  from '../managers/GitlabManager';
import Config                         from '../config/Config';
import GitlabMember                   from '../shared/types/Gitlab/GitlabMember';
import GitlabAccessLevel              from '../shared/types/Gitlab/GitlabAccessLevel';
import GitlabRepository               from '../shared/types/Gitlab/GitlabRepository';
import { AxiosError, HttpStatusCode } from 'axios';
import logger                         from '../shared/logging/WinstonLogger';
import DojoValidators                 from '../helpers/DojoValidators';
import RoutesManager               from '../express/RoutesManager.js';
import ParamsValidatorMiddleware   from '../middlewares/ParamsValidatorMiddleware.js';
import SecurityMiddleware          from '../middlewares/SecurityMiddleware.js';
import SecurityCheckType           from '../types/SecurityCheckType.js';
import GitlabManager               from '../managers/GitlabManager.js';
import Config                      from '../config/Config.js';
import logger                      from '../shared/logging/WinstonLogger.js';
import DojoValidators              from '../helpers/DojoValidators.js';
import { Prisma }                  from '@prisma/client';
import db                             from '../helpers/DatabaseHelper';
import { Assignment }                 from '../types/DatabaseTypes';
import AssignmentManager              from '../managers/AssignmentManager';
import GitlabVisibility               from '../shared/types/Gitlab/GitlabVisibility';
import db                          from '../helpers/DatabaseHelper.js';
import { Assignment }              from '../types/DatabaseTypes.js';
import AssignmentManager           from '../managers/AssignmentManager.js';
import fs                          from 'fs';
import path                        from 'path';
import SharedAssignmentHelper         from '../shared/helpers/Dojo/SharedAssignmentHelper';
import GlobalHelper                   from '../helpers/GlobalHelper';
import DojoStatusCode                 from '../shared/types/Dojo/DojoStatusCode';
import DojoModelsHelper               from '../helpers/DojoModelsHelper';
import SharedAssignmentHelper      from '../shared/helpers/Dojo/SharedAssignmentHelper.js';
import GlobalHelper                from '../helpers/GlobalHelper.js';
import DojoStatusCode              from '../shared/types/Dojo/DojoStatusCode.js';
import DojoModelsHelper            from '../helpers/DojoModelsHelper.js';
import * as Gitlab                 from '@gitbeaker/rest';
import { GitbeakerRequestError }   from '@gitbeaker/requester-utils';


class AssignmentRoutes implements RoutesManager {
@@ -55,14 +51,14 @@ class AssignmentRoutes implements RoutesManager {
    };

    registerOnBackend(backend: Express) {
        backend.get('/assignments/:assignmentNameOrUrl', SecurityMiddleware.check(true), this.getAssignment.bind(this));
        backend.post('/assignments', SecurityMiddleware.check(true, SecurityCheckType.TEACHING_STAFF), ParamsValidatorMiddleware.validate(this.assignmentValidator), this.createAssignment.bind(this));
        backend.get('/assignments/:assignmentNameOrUrl', SecurityMiddleware.check(true), this.getAssignment.bind(this) as RequestHandler);
        backend.post('/assignments', SecurityMiddleware.check(true, SecurityCheckType.TEACHING_STAFF), ParamsValidatorMiddleware.validate(this.assignmentValidator), this.createAssignment.bind(this) as RequestHandler);

        backend.patch('/assignments/:assignmentNameOrUrl/publish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.changeAssignmentPublishedStatus(true).bind(this));
        backend.patch('/assignments/:assignmentNameOrUrl/unpublish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.changeAssignmentPublishedStatus(false).bind(this));
        backend.patch('/assignments/:assignmentNameOrUrl/publish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.changeAssignmentPublishedStatus(true).bind(this) as RequestHandler);
        backend.patch('/assignments/:assignmentNameOrUrl/unpublish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.changeAssignmentPublishedStatus(false).bind(this) as RequestHandler);

        backend.post('/assignments/:assignmentNameOrUrl/corrections', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), ParamsValidatorMiddleware.validate(this.assignmentAddCorrigeValidator), this.linkUpdateAssignmentCorrection(false).bind(this));
        backend.patch('/assignments/:assignmentNameOrUrl/corrections/:exerciseIdOrUrl', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.linkUpdateAssignmentCorrection(true).bind(this));
        backend.post('/assignments/:assignmentNameOrUrl/corrections', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), ParamsValidatorMiddleware.validate(this.assignmentAddCorrigeValidator), this.linkUpdateAssignmentCorrection(false).bind(this) as RequestHandler);
        backend.patch('/assignments/:assignmentNameOrUrl/corrections/:exerciseIdOrUrl', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.linkUpdateAssignmentCorrection(true).bind(this) as RequestHandler);
    }

    // Get an assignment by its name or gitlab url
@@ -84,62 +80,49 @@ class AssignmentRoutes implements RoutesManager {

    private async createAssignment(req: express.Request, res: express.Response) {
        const params: {
            name: string, members: Array<GitlabUser>, template: string
            name: string, members: Array<Gitlab.UserSchema>, template: string
        } = req.body;
        params.members = [ await req.session.profile.gitlabProfile.value, ...params.members ];
        params.members = params.members.removeObjectDuplicates(gitlabUser => gitlabUser.id);


        let repository: GitlabRepository;
        let repository: Gitlab.ProjectSchema;
        try {
            repository = await GitlabManager.createRepository(params.name, Config.assignment.default.description.replace('{{ASSIGNMENT_NAME}}', params.name), Config.assignment.default.visibility, Config.assignment.default.initReadme, Config.gitlab.group.assignments, Config.assignment.default.sharedRunnersEnabled, Config.assignment.default.wikiEnabled, params.template);
        } catch ( error ) {
            logger.error('Repo creation error');
            logger.error(error);
            logger.error(JSON.stringify(error));

            if ( error instanceof AxiosError ) {
                if ( error.response?.data.message.name && error.response.data.message.name == 'has already been taken' ) {
                    return res.status(StatusCodes.CONFLICT).send();
            if ( error instanceof GitbeakerRequestError ) {
                if ( error.cause?.description ) {
                    const description = error.cause.description as unknown;
                    if ( GlobalHelper.isRepoNameAlreadyTaken(description) ) {
                        req.session.sendResponse(res, StatusCodes.CONFLICT, {}, `Repository name has already been taken`, DojoStatusCode.ASSIGNMENT_NAME_CONFLICT);
                        return;
                    }
                }

                return res.status(error.response?.status ?? HttpStatusCode.InternalServerError).send();
                req.session.sendResponse(res, error.cause?.response.status ?? StatusCodes.INTERNAL_SERVER_ERROR);
                return;
            }

            return res.status(StatusCodes.INTERNAL_SERVER_ERROR).send();
            req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR);
            return;
        }

        await new Promise(resolve => setTimeout(resolve, Config.gitlab.repository.timeoutAfterCreation));

        try {
            await GitlabManager.protectBranch(repository.id, '*', true, GitlabAccessLevel.DEVELOPER, GitlabAccessLevel.DEVELOPER, GitlabAccessLevel.OWNER);

            await GitlabManager.addRepositoryBadge(repository.id, Config.gitlab.badges.pipeline.link, Config.gitlab.badges.pipeline.imageUrl, 'Pipeline Status');
        } catch ( error ) {
            return GlobalHelper.repositoryCreationError('Repo params error', error, req, res, DojoStatusCode.ASSIGNMENT_CREATION_GITLAB_ERROR, DojoStatusCode.ASSIGNMENT_CREATION_INTERNAL_ERROR, repository);
        }

        try {
            await GitlabManager.deleteFile(repository.id, '.gitlab-ci.yml', 'Remove .gitlab-ci.yml');
        } catch ( error ) { /* empty */ }
        const repoCreationFnExec = GlobalHelper.repoCreationFnExecCreator(req, res, DojoStatusCode.ASSIGNMENT_CREATION_GITLAB_ERROR, DojoStatusCode.ASSIGNMENT_CREATION_INTERNAL_ERROR, repository);

        try {
            await GitlabManager.createFile(repository.id, '.gitlab-ci.yml', fs.readFileSync(path.join(__dirname, '../../assets/assignment_gitlab_ci.yml'), 'base64'), 'Add .gitlab-ci.yml (DO NOT MODIFY THIS FILE)');
        } catch ( error ) {
            return GlobalHelper.repositoryCreationError('CI file error', error, req, res, DojoStatusCode.ASSIGNMENT_CREATION_GITLAB_ERROR, DojoStatusCode.ASSIGNMENT_CREATION_INTERNAL_ERROR, repository);
        }
            await repoCreationFnExec(() => GitlabManager.protectBranch(repository.id, '*', true, Gitlab.AccessLevel.DEVELOPER, Gitlab.AccessLevel.DEVELOPER, Gitlab.AccessLevel.ADMIN), 'Branch protection modification error');
            await repoCreationFnExec(() => GitlabManager.addRepositoryBadge(repository.id, Config.gitlab.badges.pipeline.link, Config.gitlab.badges.pipeline.imageUrl, 'Pipeline Status'), 'Pipeline badge addition error');
            await repoCreationFnExec(() => GitlabManager.deleteFile(repository.id, '.gitlab-ci.yml', 'Remove .gitlab-ci.yml'));
            await repoCreationFnExec(() => GitlabManager.createFile(repository.id, '.gitlab-ci.yml', fs.readFileSync(path.join(__dirname, '../../assets/assignment_gitlab_ci.yml'), 'base64'), 'Add .gitlab-ci.yml (DO NOT MODIFY THIS FILE)'), 'CI/CD file creation error');

        try {
            await Promise.all(params.members.map(member => member.id).map(async (memberId: number): Promise<GitlabMember | false> => {
                try {
                    return await GitlabManager.addRepositoryMember(repository.id, memberId, GitlabAccessLevel.DEVELOPER);
                } catch ( error ) {
                    logger.error('Add member error');
                    logger.error(error);
                    return false;
                }
            }));
            await repoCreationFnExec(() => Promise.all(params.members.map(member => member.id).map(GlobalHelper.addRepoMember(repository.id))), 'Add repository members error');

            const assignment: Assignment = await db.assignment.create({
            const assignment: Assignment = await repoCreationFnExec(() => db.assignment.create({
                                                                                                   data: {
                                                                                                       name              : repository.name,
                                                                                                       gitlabId          : repository.id,
@@ -161,11 +144,11 @@ class AssignmentRoutes implements RoutesManager {
                                                                                                           }) ]
                                                                                                       }
                                                                                                   }
                                                                      }) as unknown as Assignment;
                                                                                               }), 'Database error') as Assignment;

            return req.session.sendResponse(res, StatusCodes.OK, assignment);
            req.session.sendResponse(res, StatusCodes.OK, assignment);
        } catch ( error ) {
            return GlobalHelper.repositoryCreationError('DB error', error, req, res, DojoStatusCode.ASSIGNMENT_CREATION_GITLAB_ERROR, DojoStatusCode.ASSIGNMENT_CREATION_INTERNAL_ERROR, repository);
            /* Empty */
        }
    }

@@ -174,12 +157,13 @@ class AssignmentRoutes implements RoutesManager {
            if ( publish ) {
                const isPublishable = await SharedAssignmentHelper.isPublishable(req.boundParams.assignment!.gitlabId);
                if ( !isPublishable.isPublishable ) {
                    return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, { lastPipeline: isPublishable.lastPipeline }, isPublishable.status?.message, isPublishable.status?.code);
                    req.session.sendResponse(res, StatusCodes.BAD_REQUEST, { lastPipeline: isPublishable.lastPipeline }, isPublishable.status?.message, isPublishable.status?.code);
                    return;
                }
            }

            try {
                await GitlabManager.changeRepositoryVisibility(req.boundParams.assignment!.gitlabId, publish ? GitlabVisibility.INTERNAL : GitlabVisibility.PRIVATE);
                await GitlabManager.changeRepositoryVisibility(req.boundParams.assignment!.gitlabId, publish ? 'internal' : 'private');

                await db.assignment.update({
                                               where: {
@@ -192,20 +176,21 @@ class AssignmentRoutes implements RoutesManager {

                req.session.sendResponse(res, StatusCodes.OK);
            } catch ( error ) {
                if ( error instanceof AxiosError ) {
                    res.status(error.response?.status ?? HttpStatusCode.InternalServerError).send();
                logger.error(JSON.stringify(error));

                if ( error instanceof GitbeakerRequestError ) {
                    req.session.sendResponse(res, error.cause?.response.status ?? StatusCodes.INTERNAL_SERVER_ERROR, undefined, 'Error while updating the assignment state');
                    return;
                }

                logger.error(error);
                res.status(StatusCodes.INTERNAL_SERVER_ERROR).send();
                req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, undefined, 'Error while updating the assignment state');
            }
        };
    }

    private linkUpdateAssignmentCorrection(isUpdate: boolean): (req: express.Request, res: express.Response) => Promise<void> {
        return async (req: express.Request, res: express.Response): Promise<void> => {
            if ( req.boundParams.exercise?.assignmentName != req.boundParams.assignment?.name ) {
            if ( req.boundParams.exercise?.assignmentName !== req.boundParams.assignment?.name ) {
                return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, undefined, 'The exercise does not belong to the assignment', DojoStatusCode.ASSIGNMENT_EXERCISE_NOT_RELATED);
            }

@@ -222,7 +207,7 @@ class AssignmentRoutes implements RoutesManager {
            const lastCommit = await GitlabManager.getRepositoryLastCommit(req.boundParams.exercise!.gitlabId);
            if ( lastCommit ) {
                if ( !isUpdate ) {
                    await GitlabManager.changeRepositoryVisibility(req.boundParams.exercise!.gitlabId, GitlabVisibility.INTERNAL);
                    await GitlabManager.changeRepositoryVisibility(req.boundParams.exercise!.gitlabId, 'internal');
                }

                await db.exercise.update({
Original line number Diff line number Diff line
import { Express }                 from 'express-serve-static-core';
import express         from 'express';
import express, { RequestHandler } from 'express';
import { StatusCodes }             from 'http-status-codes';
import RoutesManager   from '../express/RoutesManager';
import RoutesManager               from '../express/RoutesManager.js';


class BaseRoutes implements RoutesManager {
    registerOnBackend(backend: Express) {
        backend.get('/', this.homepage.bind(this));
        backend.get('/health_check', this.healthCheck.bind(this));
        backend.get('/', this.homepage.bind(this) as RequestHandler);
        backend.get('/health_check', this.healthCheck.bind(this) as RequestHandler);
    }

    private async homepage(req: express.Request, res: express.Response) {
Original line number Diff line number Diff line
import { Express }                 from 'express-serve-static-core';
import express                   from 'express';
import express, { RequestHandler } from 'express';
import * as ExpressValidator       from 'express-validator';
import { StatusCodes }             from 'http-status-codes';
import RoutesManager             from '../express/RoutesManager';
import ParamsValidatorMiddleware from '../middlewares/ParamsValidatorMiddleware';
import SecurityMiddleware        from '../middlewares/SecurityMiddleware';
import GitlabUser                from '../shared/types/Gitlab/GitlabUser';
import GitlabManager             from '../managers/GitlabManager';
import Config                    from '../config/Config';
import GitlabRepository          from '../shared/types/Gitlab/GitlabRepository';
import { AxiosError }            from 'axios';
import logger                    from '../shared/logging/WinstonLogger';
import DojoValidators            from '../helpers/DojoValidators';
import RoutesManager               from '../express/RoutesManager.js';
import ParamsValidatorMiddleware   from '../middlewares/ParamsValidatorMiddleware.js';
import SecurityMiddleware          from '../middlewares/SecurityMiddleware.js';
import GitlabManager               from '../managers/GitlabManager.js';
import Config                      from '../config/Config.js';
import logger                      from '../shared/logging/WinstonLogger.js';
import DojoValidators              from '../helpers/DojoValidators.js';
import { v4 as uuidv4 }            from 'uuid';
import GitlabMember              from '../shared/types/Gitlab/GitlabMember';
import GitlabAccessLevel         from '../shared/types/Gitlab/GitlabAccessLevel';
import { Prisma }                  from '@prisma/client';
import { Assignment, Exercise }  from '../types/DatabaseTypes';
import db                        from '../helpers/DatabaseHelper';
import SecurityCheckType         from '../types/SecurityCheckType';
import GitlabTreeFile            from '../shared/types/Gitlab/GitlabTreeFile';
import GitlabFile                from '../shared/types/Gitlab/GitlabFile';
import GitlabTreeFileType        from '../shared/types/Gitlab/GitlabTreeFileType';
import { Assignment, Exercise }    from '../types/DatabaseTypes.js';
import db                          from '../helpers/DatabaseHelper.js';
import SecurityCheckType           from '../types/SecurityCheckType.js';
import JSON5                       from 'json5';
import fs                          from 'fs';
import path                        from 'path';
import AssignmentFile            from '../shared/types/Dojo/AssignmentFile';
import ExerciseResultsFile       from '../shared/types/Dojo/ExerciseResultsFile';
import DojoStatusCode            from '../shared/types/Dojo/DojoStatusCode';
import GlobalHelper              from '../helpers/GlobalHelper';
import { IFileDirStat }          from '../shared/helpers/recursiveFilesStats/RecursiveFilesStats';
import ExerciseManager           from '../managers/ExerciseManager';
import AssignmentFile              from '../shared/types/Dojo/AssignmentFile.js';
import ExerciseResultsFile         from '../shared/types/Dojo/ExerciseResultsFile.js';
import DojoStatusCode              from '../shared/types/Dojo/DojoStatusCode.js';
import GlobalHelper                from '../helpers/GlobalHelper.js';
import { IFileDirStat }            from '../shared/helpers/recursiveFilesStats/RecursiveFilesStats.js';
import ExerciseManager             from '../managers/ExerciseManager.js';
import * as Gitlab                 from '@gitbeaker/rest';
import GitlabTreeFileType          from '../shared/types/Gitlab/GitlabTreeFileType.js';
import { GitbeakerRequestError }   from '@gitbeaker/requester-utils';


class ExerciseRoutes implements RoutesManager {
@@ -70,104 +65,108 @@ class ExerciseRoutes implements RoutesManager {
    };

    registerOnBackend(backend: Express) {
        backend.post('/assignments/:assignmentNameOrUrl/exercises', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_IS_PUBLISHED), ParamsValidatorMiddleware.validate(this.exerciseValidator), this.createExercise.bind(this));
        backend.post('/assignments/:assignmentNameOrUrl/exercises', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_IS_PUBLISHED), ParamsValidatorMiddleware.validate(this.exerciseValidator), this.createExercise.bind(this) as RequestHandler);

        backend.get('/exercises/:exerciseIdOrUrl/assignment', SecurityMiddleware.check(false, SecurityCheckType.EXERCISE_SECRET), this.getAssignment.bind(this));
        backend.get('/exercises/:exerciseIdOrUrl/assignment', SecurityMiddleware.check(false, SecurityCheckType.EXERCISE_SECRET), this.getAssignment.bind(this) as RequestHandler);

        backend.post('/exercises/:exerciseIdOrUrl/results', SecurityMiddleware.check(false, SecurityCheckType.EXERCISE_SECRET), ParamsValidatorMiddleware.validate(this.resultValidator), this.createResult.bind(this));
        backend.post('/exercises/:exerciseIdOrUrl/results', SecurityMiddleware.check(false, SecurityCheckType.EXERCISE_SECRET), ParamsValidatorMiddleware.validate(this.resultValidator), this.createResult.bind(this) as RequestHandler);
    }

    private getExerciseName(assignment: Assignment, members: Array<GitlabUser>, suffix: number): string {
        return `DojoEx - ${ assignment.name } - ${ members.map(member => member.username).sort((a, b) => a.localeCompare(b)).join(' + ') }${ suffix > 0 ? ` - ${ suffix }` : '' }`;
    private getExerciseName(assignment: Assignment, members: Array<Gitlab.UserSchema>, suffix: number): string {
        const memberNames: string = members.map(member => member.username).sort((a, b) => a.localeCompare(b)).join(' + ');
        const suffixString: string = suffix > 0 ? ` - ${ suffix }` : '';
        return `DojoEx - ${ assignment.name } - ${ memberNames }${ suffixString }`;
    }

    private getExercisePath(assignment: Assignment, exerciseId: string): string {
        return `dojo-ex_${ (assignment.gitlabLastInfo as unknown as GitlabRepository).path }_${ exerciseId }`;
        return `dojo-ex_${ (assignment.gitlabLastInfo as unknown as Gitlab.ProjectSchema).path }_${ exerciseId }`;
    }

    private async createExercise(req: express.Request, res: express.Response) {
        const params: { members: Array<GitlabUser> } = req.body;
        params.members = [ await req.session.profile.gitlabProfile!.value, ...params.members ].removeObjectDuplicates(gitlabUser => gitlabUser.id);
        const assignment: Assignment = req.boundParams.assignment!;


    private async checkExerciseLimit(assignment: Assignment, members: Array<Gitlab.UserSchema>): Promise<Array<Gitlab.UserSchema>> {
        const exercises: Array<Exercise> | undefined = await ExerciseManager.getFromAssignment(assignment.name, { members: true });
        const reachedLimitUsers: Array<GitlabUser> = [];
        if ( exercises ) {
            for ( const member of params.members ) {
        const reachedLimitUsers: Array<Gitlab.UserSchema> = [];
        if ( exercises.length > 0 ) {
            for ( const member of members ) {
                const exerciseCount: number = exercises.filter(exercise => exercise.members.findIndex(exerciseMember => exerciseMember.id === member.id) !== -1).length;
                if ( exerciseCount >= Config.exercise.maxPerAssignment ) {
                    reachedLimitUsers.push(member);
                }
            }
        }
        if ( reachedLimitUsers.length > 0 ) {
            return req.session.sendResponse(res, StatusCodes.INSUFFICIENT_SPACE_ON_RESOURCE, reachedLimitUsers, 'Max exercise per assignment reached', DojoStatusCode.MAX_EXERCISE_PER_ASSIGNMENT_REACHED);
        }

        return reachedLimitUsers;
    }

        const exerciseId: string = uuidv4();
        const secret: string = uuidv4();
        let repository!: GitlabRepository;
    private async createExerciseRepository(assignment: Assignment, members: Array<Gitlab.UserSchema>, exerciseId: string, req: express.Request, res: express.Response): Promise<Gitlab.ProjectSchema | undefined> {
        let repository!: Gitlab.ProjectSchema;

        let suffix: number = 0;
        do {
            try {
                repository = await GitlabManager.forkRepository((assignment.gitlabCreationInfo as unknown as GitlabRepository).id, this.getExerciseName(assignment, params.members, suffix), this.getExercisePath(req.boundParams.assignment!, exerciseId), Config.exercise.default.description.replace('{{ASSIGNMENT_NAME}}', assignment.name), Config.exercise.default.visibility, Config.gitlab.group.exercises);
                repository = await GitlabManager.forkRepository((assignment.gitlabCreationInfo as unknown as Gitlab.ProjectSchema).id, this.getExerciseName(assignment, members, suffix), this.getExercisePath(req.boundParams.assignment!, exerciseId), Config.exercise.default.description.replace('{{ASSIGNMENT_NAME}}', assignment.name), Config.exercise.default.visibility, Config.gitlab.group.exercises);
                break;
            } catch ( error ) {
                logger.error('Repo creation error');
                logger.error(error);
                logger.error(JSON.stringify(error));

                if ( error instanceof AxiosError ) {
                    if ( error.response?.data.message.name && error.response.data.message.name == 'has already been taken' ) {
                if ( error instanceof GitbeakerRequestError && error.cause?.description ) {
                    const description = error.cause.description as unknown;
                    if ( GlobalHelper.isRepoNameAlreadyTaken(description) ) {
                        suffix++;
                    } else {
                        return req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, {}, 'Unknown gitlab error while forking repository', DojoStatusCode.EXERCISE_CREATION_GITLAB_ERROR);
                        req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, {}, 'Unknown gitlab error while forking repository', DojoStatusCode.EXERCISE_CREATION_GITLAB_ERROR);
                        return undefined;
                    }
                } else {
                    return req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, {}, 'Unknown error while forking repository', DojoStatusCode.EXERCISE_CREATION_INTERNAL_ERROR);
                    req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, {}, 'Unknown error while forking repository', DojoStatusCode.EXERCISE_CREATION_INTERNAL_ERROR);
                    return undefined;
                }
            }
        } while ( suffix < Config.exercise.maxSameName );

        if ( suffix >= Config.exercise.maxSameName ) {
            logger.error('Max exercise with same name reached');
            return res.status(StatusCodes.INSUFFICIENT_SPACE_ON_RESOURCE).send();
        return repository;
    }

    private async createExercise(req: express.Request, res: express.Response) {
        const params: { members: Array<Gitlab.UserSchema> } = req.body;
        params.members = [ await req.session.profile.gitlabProfile.value, ...params.members ].removeObjectDuplicates(gitlabUser => gitlabUser.id);
        const assignment: Assignment = req.boundParams.assignment!;


        const reachedLimitUsers: Array<Gitlab.UserSchema> = await this.checkExerciseLimit(assignment, params.members);
        if ( reachedLimitUsers.length > 0 ) {
            req.session.sendResponse(res, StatusCodes.INSUFFICIENT_SPACE_ON_RESOURCE, reachedLimitUsers, 'Max exercise per assignment reached', DojoStatusCode.MAX_EXERCISE_PER_ASSIGNMENT_REACHED);
            return;
        }


        const exerciseId: string = uuidv4();
        const secret: string = uuidv4();
        const repository: Gitlab.ProjectSchema | undefined = await this.createExerciseRepository(assignment, params.members, exerciseId, req, res);

        if ( !repository ) {
            return;
        }

        await new Promise(resolve => setTimeout(resolve, Config.gitlab.repository.timeoutAfterCreation));

        const repoCreationFnExec = GlobalHelper.repoCreationFnExecCreator(req, res, DojoStatusCode.EXERCISE_CREATION_GITLAB_ERROR, DojoStatusCode.EXERCISE_CREATION_INTERNAL_ERROR, repository);

        try {
            await GitlabManager.protectBranch(repository.id, '*', false, GitlabAccessLevel.DEVELOPER, GitlabAccessLevel.DEVELOPER, GitlabAccessLevel.OWNER);
            await repoCreationFnExec(() => GitlabManager.protectBranch(repository.id, '*', false, Gitlab.AccessLevel.DEVELOPER, Gitlab.AccessLevel.DEVELOPER, Gitlab.AccessLevel.ADMIN), 'Branch protection modification error');
            await repoCreationFnExec(() => GitlabManager.addRepositoryBadge(repository.id, Config.gitlab.badges.pipeline.link, Config.gitlab.badges.pipeline.imageUrl, 'Pipeline Status'), 'Pipeline badge addition error');

            await repoCreationFnExec(async () => {
                await GitlabManager.addRepositoryVariable(repository.id, 'DOJO_EXERCISE_ID', exerciseId, false, true);
                await GitlabManager.addRepositoryVariable(repository.id, 'DOJO_SECRET', secret, false, true);
                await GitlabManager.addRepositoryVariable(repository.id, 'DOJO_RESULTS_FOLDER', Config.exercise.pipelineResultsFolder, false, false);
            }, 'Pipeline variables addition error');

            await GitlabManager.addRepositoryBadge(repository.id, Config.gitlab.badges.pipeline.link, Config.gitlab.badges.pipeline.imageUrl, 'Pipeline Status');
        } catch ( error ) {
            return GlobalHelper.repositoryCreationError('Repo params error', error, req, res, DojoStatusCode.EXERCISE_CREATION_GITLAB_ERROR, DojoStatusCode.EXERCISE_CREATION_INTERNAL_ERROR, repository);
        }
            await repoCreationFnExec(() => GitlabManager.updateFile(repository.id, '.gitlab-ci.yml', fs.readFileSync(path.join(__dirname, '../../assets/exercise_gitlab_ci.yml'), 'base64'), 'Add .gitlab-ci.yml (DO NOT MODIFY THIS FILE)'), 'CI/CD file update error');

        try {
            await GitlabManager.updateFile(repository.id, '.gitlab-ci.yml', fs.readFileSync(path.join(__dirname, '../../assets/exercise_gitlab_ci.yml'), 'base64'), 'Add .gitlab-ci.yml (DO NOT MODIFY THIS FILE)');
        } catch ( error ) {
            return GlobalHelper.repositoryCreationError('CI file update error', error, req, res, DojoStatusCode.EXERCISE_CREATION_GITLAB_ERROR, DojoStatusCode.EXERCISE_CREATION_INTERNAL_ERROR, repository);
        }

        try {
            await Promise.all([ ...new Set([ ...assignment.staff.map(user => user.id), ...params.members.map(member => member.id) ]) ].map(async (memberId: number): Promise<GitlabMember | false> => {
                try {
                    return await GitlabManager.addRepositoryMember(repository.id, memberId, GitlabAccessLevel.DEVELOPER);
                } catch ( error ) {
                    logger.error('Add member error');
                    logger.error(error);
                    return false;
                }
            }));
            await repoCreationFnExec(async () => Promise.all([ ...new Set([ ...assignment.staff, ...params.members ].map(member => member.id)) ].map(GlobalHelper.addRepoMember(repository.id))), 'Add repository members error');

            const exercise: Exercise = await db.exercise.create({
            const exercise: Exercise = await repoCreationFnExec(() => db.exercise.create({
                                                                                             data: {
                                                                                                 id                : exerciseId,
                                                                                                 assignmentName    : assignment.name,
@@ -192,19 +191,20 @@ class ExerciseRoutes implements RoutesManager {
                                                                                                     }) ]
                                                                                                 }
                                                                                             }
                                                                }) as unknown as Exercise;
                                                                                         })) as Exercise;

            return req.session.sendResponse(res, StatusCodes.OK, exercise);
            req.session.sendResponse(res, StatusCodes.OK, exercise);
            return;
        } catch ( error ) {
            return GlobalHelper.repositoryCreationError('DB error', error, req, res, DojoStatusCode.EXERCISE_CREATION_GITLAB_ERROR, DojoStatusCode.EXERCISE_CREATION_INTERNAL_ERROR, repository);
            /* Empty */
        }
    }

    private async getAssignment(req: express.Request, res: express.Response) {
        const repoTree: Array<GitlabTreeFile> = await GitlabManager.getRepositoryTree(req.boundParams.exercise!.assignment.gitlabId);
        const repoTree: Array<Gitlab.RepositoryTreeSchema> = await GitlabManager.getRepositoryTree(req.boundParams.exercise!.assignment.gitlabId);

        let assignmentHjsonFile!: GitlabFile;
        const immutableFiles: Array<GitlabFile> = await Promise.all(Config.assignment.baseFiles.map(async (baseFile: string) => {
        let assignmentHjsonFile!: Gitlab.RepositoryFileExpandedSchema;
        const immutableFiles: Array<Gitlab.RepositoryFileExpandedSchema> = await Promise.all(Config.assignment.baseFiles.map(async (baseFile: string) => {
            const file = await GitlabManager.getFile(req.boundParams.exercise!.assignment.gitlabId, baseFile);

            if ( baseFile === Config.assignment.filename ) {
@@ -214,12 +214,12 @@ class ExerciseRoutes implements RoutesManager {
            return file;
        }));

        const dojoAssignmentFile: AssignmentFile = JSON5.parse(atob(assignmentHjsonFile.content)) as AssignmentFile;
        const dojoAssignmentFile: AssignmentFile = JSON5.parse(atob(assignmentHjsonFile.content));

        const immutablePaths = dojoAssignmentFile.immutable.map(fileDescriptor => fileDescriptor.path);

        await Promise.all(repoTree.map(async gitlabTreeFile => {
            if ( gitlabTreeFile.type == GitlabTreeFileType.BLOB ) {
            if ( gitlabTreeFile.type === GitlabTreeFileType.BLOB.valueOf() ) {
                for ( const immutablePath of immutablePaths ) {
                    if ( gitlabTreeFile.path.startsWith(immutablePath) ) {
                        immutableFiles.push(await GitlabManager.getFile(req.boundParams.exercise!.assignment.gitlabId, gitlabTreeFile.path));
Original line number Diff line number Diff line
import { Express }                 from 'express-serve-static-core';
import express            from 'express';
import RoutesManager      from '../express/RoutesManager';
import SecurityMiddleware from '../middlewares/SecurityMiddleware';
import SecurityCheckType  from '../types/SecurityCheckType';
import GitlabManager      from '../managers/GitlabManager';
import express, { RequestHandler } from 'express';
import RoutesManager               from '../express/RoutesManager.js';
import SecurityMiddleware          from '../middlewares/SecurityMiddleware.js';
import SecurityCheckType           from '../types/SecurityCheckType.js';
import GitlabManager               from '../managers/GitlabManager.js';


class GitlabRoutes implements RoutesManager {
    registerOnBackend(backend: Express) {
        backend.get('/gitlab/project/:gitlabProjectIdOrNamespace/checkTemplateAccess', SecurityMiddleware.check(true, SecurityCheckType.TEACHING_STAFF), this.checkTemplateAccess.bind(this));
        backend.get('/gitlab/project/:gitlabProjectIdOrNamespace/checkTemplateAccess', SecurityMiddleware.check(true, SecurityCheckType.TEACHING_STAFF), this.checkTemplateAccess.bind(this) as RequestHandler);
    }

    private async checkTemplateAccess(req: express.Request, res: express.Response) {
        const gitlabProjectIdOrNamespace: string = req.params.gitlabProjectIdOrNamespace;

        return res.status(await GitlabManager.checkTemplateAccess(gitlabProjectIdOrNamespace, req)).send();
        await GitlabManager.checkTemplateAccess(gitlabProjectIdOrNamespace, req, res);
    }
}

Original line number Diff line number Diff line
import { Express }                 from 'express-serve-static-core';
import express                   from 'express';
import express, { RequestHandler } from 'express';
import * as ExpressValidator       from 'express-validator';
import { StatusCodes }             from 'http-status-codes';
import RoutesManager             from '../express/RoutesManager';
import ParamsValidatorMiddleware from '../middlewares/ParamsValidatorMiddleware';
import SecurityMiddleware        from '../middlewares/SecurityMiddleware';
import GitlabManager             from '../managers/GitlabManager';
import UserManager               from '../managers/UserManager';
import DojoStatusCode            from '../shared/types/Dojo/DojoStatusCode';
import SharedGitlabManager       from '../shared/managers/SharedGitlabManager';
import Config                    from '../config/Config';
import RoutesManager               from '../express/RoutesManager.js';
import ParamsValidatorMiddleware   from '../middlewares/ParamsValidatorMiddleware.js';
import SecurityMiddleware          from '../middlewares/SecurityMiddleware.js';
import GitlabManager               from '../managers/GitlabManager.js';
import UserManager                 from '../managers/UserManager.js';
import DojoStatusCode              from '../shared/types/Dojo/DojoStatusCode.js';
import Config                      from '../config/Config.js';


class SessionRoutes implements RoutesManager {
@@ -32,9 +31,9 @@ class SessionRoutes implements RoutesManager {
    };

    registerOnBackend(backend: Express) {
        backend.post('/login', ParamsValidatorMiddleware.validate(this.loginValidator), this.login.bind(this));
        backend.post('/refresh_tokens', ParamsValidatorMiddleware.validate(this.refreshTokensValidator), this.refreshTokens.bind(this));
        backend.get('/test_session', SecurityMiddleware.check(true), this.testSession.bind(this));
        backend.post('/login', ParamsValidatorMiddleware.validate(this.loginValidator), this.login.bind(this) as RequestHandler);
        backend.post('/refresh_tokens', ParamsValidatorMiddleware.validate(this.refreshTokensValidator), this.refreshTokens.bind(this) as RequestHandler);
        backend.get('/test_session', SecurityMiddleware.check(true), this.testSession.bind(this) as RequestHandler);
    }

    private async login(req: express.Request, res: express.Response) {
@@ -64,7 +63,7 @@ class SessionRoutes implements RoutesManager {
                refreshToken: string
            } = req.body;

            const gitlabTokens = await SharedGitlabManager.getTokens(params.refreshToken, true, Config.login.gitlab.client.secret);
            const gitlabTokens = await GitlabManager.getTokens(params.refreshToken, true, Config.login.gitlab.client.secret);

            req.session.sendResponse(res, StatusCodes.OK, gitlabTokens);
        } catch ( error ) {
Original line number Diff line number Diff line
import { Prisma }  from '@prisma/client';
import LazyVal    from '../shared/helpers/LazyVal';
import GitlabUser from '../shared/types/Gitlab/GitlabUser';
import LazyVal     from '../shared/helpers/LazyVal.js';
import * as Gitlab from '@gitbeaker/rest';


const userBase = Prisma.validator<Prisma.UserDefaultArgs>()({
@@ -29,7 +29,7 @@ const resultBase = Prisma.validator<Prisma.ResultDefaultArgs>()({
export type User = Prisma.UserGetPayload<typeof userBase> & {
    isTeachingStaff: boolean
    isAdmin: boolean
    gitlabProfile: LazyVal<GitlabUser>
    gitlabProfile: LazyVal<Gitlab.UserSchema>
}
export type Exercise = Prisma.ExerciseGetPayload<typeof exerciseBase> & {
    isCorrection: boolean
Original line number Diff line number Diff line
import Session                  from '../../controllers/Session';
import { Assignment, Exercise } from '../DatabaseTypes';
import Session                  from '../../controllers/Session.js';
import { Assignment, Exercise } from '../DatabaseTypes.js';

// to make the file a module and avoid the TypeScript error
export {};
+2 −38
Original line number Diff line number Diff line
# DojoBackendAPI
# Documentation of `The Dojo Backend API`

## Development environment

### Env vars

You can decrypt env var stored in the `.env.vault` file with the following commands in the `ExpressAPI` folder:
```bash
> npx dotenv-vault local keys
 environment DOTENV_KEY
 ─────────── ─────────────────────────────────────────────────────────────────────────
 development dotenv://:key_1234@dotenv.local/vault/.env.vault?environment=development

Set DOTENV_KEY on your server

> npx dotenv-vault local decrypt dotenv://:key_1234@dotenv.local/vault/.env.vault?environment=development > .env.development
```

**The `.env.keys` file have to be requested to the project maintainers.**

### Database

For development, you can use the docker-compose file in the `Resources/DevInfra/` folder.

```bash
docker-compose -f Resources/DevInfra/docker-compose.yml up -d
```

This will run a MariaDB database on port `59231` with the following credentials: `root:9buz7f312479g6234f1gnioubervw79b8z`

A second container is created with the Adminer tool on port `62394`.

#### Structure creation and seeding

The following command will create the database structure and seed it with some exemple data.

```bash
npm run database:deploy:dev
```
 No newline at end of file
All documentations are available on the [Dojo documentation website](https://www.hepiapp.ch/) : https://www.hepiapp.ch/.
 No newline at end of file
Original line number Diff line number Diff line
# How to setup your development environment

## Introduction

This tutorial describes how to setup your development environment for building the Dojo API by detailing the 
prerequisites and dependencies needed.

## Technologies

The API is built using [NodeJS](https://nodejs.org/en/) and [NPM](https://www.npmjs.com/).

The programming language used is [Typescript](https://www.typescriptlang.org/) v5.


## Prerequisites

In order to build the API you will need the following tools:
- [NodeJS](https://nodejs.org/en/) (version 20 or higher)
- [NPM](https://www.npmjs.com/) (version 10 or higher)

Install NodeJS and NPM by following the instructions on the [official website](https://nodejs.org/en/download/package-manager).
Or via Node Version Manager (NVM) by following the instructions on the [official website](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm).


## Dependencies

Here are the main dependencies used by the API (you do not need to install them manually or globally on your system):
- [Axios](https://www.npmjs.com/package/axios): a promise-based HTTP client for the browser and Node.js. It is
used to make HTTP(S) requests to the Dojo backend and Gitlab.
- [Dotenv](https://www.npmjs.com/package/dotenv): used to load environment variables from a .env file.
- [Dotenv-vault](https://www.npmjs.com/package/dotenv-vault): a CLI to sync .env files across machines, 
environments, and team members.
- [Express](https://www.npmjs.com/package/express): a minimal and flexible Node.js web application framework that 
provides a robust set of features for web and mobile applications. Used to create the API server.
- [express-validator](https://www.npmjs.com/package/express-validator): used to validate and sanitize express requests.
- [Morgan](https://www.npmjs.com/package/morgan): used to log HTTP requests. 
- [JsonWebToken](https://www.npmjs.com/package/jsonwebtoken): used to generate and validate [JSON Web Tokens](https://jwt.io/).
- [Prisma](https://www.npmjs.com/package/prisma): a modern database access & ORM for Node.js. Used to construct (from 
scratch and migrations) and interact with the database.
- [Winston](https://www.npmjs.com/package/winston): used for logging purposes. Used in combination with Morgan to 
log HTTP requests in the format defined with Winston.
- [zod](https://www.npmjs.com/package/zod): a TypeScript-first schema validation with static type inference. Used
in the projet to validate json files created by the user.


## Installation

First of all, you need to clone the repository:
```bash
$ git clone --recurse-submodule https://gitedu.hesge.ch/dojo_project/projects/backend/dojobackendapi.git
```

Then, you need to move to the project's directory:
```bash
$ cd dojobackendapi/ExpressAPI
```

To install the dependencies listed above you can use the following command in the base directory of the project:
```bash
$ npm install
```


## Environment variables

Environment variables are used to store sensitive information such as API keys, passwords, etc.
They are also used to store configuration information that can be changed without modifying the code.


### Using on your development machine

To use environment variables on your development machine, you need the `.env.keys` file in addition of the `.env.vault` 
file present in the repository.

**The `.env.keys` file have to be requested to the project maintainer: [Michaël Minelli](mailto:dojo@minelli.me).**


### Decrypting the environment variables

You can decrypt env var stored in the `.env.vault` file with the following commands in the project's main folder. 
Here is an example of how to decrypt the environment variables for the development environment:
```bash
$ npx dotenv-vault local keys
 environment DOTENV_KEY
 ─────────── ─────────────────────────────────────────────────────────────────────────
 development dotenv://:key_1234@dotenv.local/vault/.env.vault?environment=development

Set DOTENV_KEY on your server

$ npx dotenv-vault local decrypt 'dotenv://:key_1234@dotenv.local/vault/.env.vault?environment=development' > .env.development
```


### How to modify the environment variables

You can modify environment variables in `.env` files that you have previously decrypted. 
You also need to re-encrypt the modified `.env` files to store them in the `.env.vault` with the following command:
```bash
$ npm run dotenv:build
```


## Database

For the development, you can use the docker compose file in the `Resources/DevInfra/` folder.

The following command have to be executed in the base directory of the repository:
```bash
$ docker compose -f Resources/DevInfra/docker-compose.yml up -d
```

This will run a MariaDB database on port `59231` with the following credentials: `root:9buz7f312479g6234f1gnioubervw79b8z`

A second container is created with the Adminer tool on port `62394`.

#### Structure creation and seeding

The following command (to be executed from the `ExpressAPI` folder) will create the database structure and seed it with 
some example data.

```bash
$ npm run database:deploy:dev
```


## Run the API

To run the API (in dev mode) you can use the following command in the base directory of the project:
```bash
$ npm run start:dev
```
+0 −410
Original line number Diff line number Diff line
# How to add a new route to the Dojo API

## Introduction

This tutorial describes how to add a new route to the Dojo API. For that we take two existing routes and describe
his implementation. This route allow a member of the teaching staff of an assignment to publish / unpublish it.


## Prerequisites

All the prerequisites are described in
[How to setup your development environment](1-How-to-setup-your-development-environment) tutorial.


## Properties of the new route

### Description of the route
- `Route :` /assignments/:assignmentNameOrUrl/publish
- `Verb :` PATCH
- `Resume :` Publish an assignment
- `Protection type :` Clients_Token
- `Protection :` TeachingStaff of the assignment or Admin role

### Params of the request (url params)
- `Name :` assignmentNameOrUrl
   - `Description :` The name or the url of an assignment.
   - `Location :` Query
   - `Required :` Yes
   - `Data type :` string (path)

### Possible Response(s)
- `Code :` 200
   - `Description :` OK
   - `Content of the response :`
```json
{
  "timestamp": "1992-09-30T19:00:00.000Z",
  "code": 200,
  "description": "OK",
  "sessionToken": "JWT token (for content, see schema named 'SessionTokenJWT')",
  "data": {}
}
```
- `Code :` 401 - Standard
- `Code :` 404 - Standard


## Routes files structure

The routes files are located in the `src/routes` folder. All routes files are named with the following pattern:
`SubjectRoutes.ts` where `Subject` has to be replaced by the general subject of the routes implemented in it (f.e. 
Exercise, Assignment, Session, etc.).

### Application to our use case
In our case we will add our route to the file with the following path :
`src/routes/AssignmentRoutes.ts`.


## Routes class inheritance

All routes files must inherit from the `RoutesManager` interface. This interface is located in the
`src/express/RoutesManager.ts` file.

When you inherit from this interface you will need to implement the following method :
- `registerOnBackend(backend: Express): void;`: This method get the express backend object for register new routes.

### Apply to our use case

Now, the `src/routes/AssignmentRoutes.ts` file will look like this:
```typescript
import RoutesManager from '../express/RoutesManager';

class AssignmentRoutes implements RoutesManager {
  protected commandName: string = 'publish';

  registerOnBackend(backend: Express) {
    ...
    backend.patch('/assignments/:assignmentNameOrUrl/publish', ..., this.publishAssignment.bind(this));
    backend.patch('/assignments/:assignmentNameOrUrl/unpublish', ..., this.unpublishAssignment.bind(this));
  }

  protected async commandAction(assignmentNameOrUrl: string, options: { force: boolean }): Promise<void> {
    private async publishAssignment(req: express.Request, res: express.Response) {
      return this.changeAssignmentPublishedStatus(true)(req, res);
    }

    private async unpublishAssignment(req: express.Request, res: express.Response) {
      return this.changeAssignmentPublishedStatus(false)(req, res);
    }

    private changeAssignmentPublishedStatus(publish: boolean): (req: express.Request, res: express.Response) => Promise<void> {
      return async (req: express.Request, res: express.Response): Promise<void> => {
        ...        
      };
    }
  }

export default new AssignmentRoutes();
```


## Define request param binding

The goal of the step is to define a generic request parameter and bind functions that will complete a property in the
`request.boundParams` object. 
For exemple, we can define a function that will take the `assignmentNameOrUrl` parameter and find the assignment 
corresponding to it, add it to the request object and call the next function.

Steps : 
1. First of all, we need to add the new property to the type located in the `src/types/express/index.d.ts` file in the
boundParams object with type: `Type | undefined` with `Type` is the type of the param.

2. In the `src/middlewares/ParamsClallbackManager.ts` file we need to:
   1. Add the new property to the `boundParams` object in the `initBoundParams` method with the `undefined` value.
   2. Call the the `listenParam` method in the `registerOnBackend` method for each paramer as follows:
   ```typescript
   this.listenParam(paramName: string, backend: Express, getFunction: GetFunction, arrayOfArgsForGetFunction: Array<unknown>, boundParamsIndexName: string

### Application to our use case

1. In the `src/types/express/index.d.ts` file:
```typescript
declare global {
  namespace Express {
    interface Request {
      ...  
      boundParams: {
        ...  
        assignment: Assignment | undefined
      };
    }
  }
}
```

2. In the `src/middlewares/ParamsClallbackManager.ts` file:
```typescript
...
initBoundParams(req: express.Request) {
  if ( !req.boundParams ) {
    req.boundParams = {
      ...,
      assignment: undefined
    };
  }
}

registerOnBackend(backend: Express) {
  ...
    
  this.listenParam('assignmentNameOrUrl', backend, (AssignmentManager.get as GetFunction).bind(AssignmentManager), [ {
    exercises: true,
    staff    : true
  } ], 'assignment');
}
```


## Add security to the route

A middleware is available to check permissions. It is located in the `src/middlewares/SecurityMiddleware.ts` file.
This file does not need to be edited unless you want to add some new security tests.

You can use the function `SecurityMiddleware.check(checkIfConnected: boolean, ...checkTypes: Array<SecurityCheckType>)`
function as a middleware in the route definition. 

You have the possibility to just check if the user is connected with the first parameter. If you want to add a more
specific check you can add some parameters with the `SecurityCheckType` enum value (f.e. teaching staff, assignment
staff, etc.).

**WARNING:** The `SecurityCheckType` args array is interpreted as an `OR` condition. So if you call : 
`SecurityMiddleware.check(true, SecurityCheckType.TEACHING_STAFF, SecurityCheckType.ASSIGNMENT_STAFF)`, the middleware
will check if the user is connected and if he have the teaching staff role or in the assignment staff.

### Application to our use case

For our routes we want to test if the user is connected and if he is in the staff of the assignment. So we will
complete the routes definitions like this:
```typescript
registerOnBackend(backend: Express) {
  ...
  backend.patch('/assignments/:assignmentNameOrUrl/publish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.publishAssignment.bind(this));
  backend.patch('/assignments/:assignmentNameOrUrl/unpublish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.unpublishAssignment.bind(this));
}
```


## Control the request body

The Dojo API use the `express-validator` (based on `validator.js`) library to validate and sanitize the request body of 
requests. We use the last version of the library that is `7.0.1`. The documentation is available at the following link:
[express-validator](https://express-validator.github.io/docs/).


### Application to our use case

This tutorial will not go deeper in the `express-validator` library because we do not need have request body in our
routes. But if you need to use it, you can find examples in `src/routes/ExerciseRoutes.ts` or 
`src/routes/AssignmentRoutes.ts` file (look at the `ExpressValidator.Schema` objects and their usage).


## Define the action

When you define an action you define a function with the following signature:
```typescript
(req: express.Request, res: express.Response): Promise<void>
```

To respond to the request you need to use the following method (works even if the user is not connected):
```typescript
return req.session.sendResponse(res: express.Response, code: number, data?: unknown, descriptionOverride?: string, internalCode?: number)
```
Where : 
- `res` is the express response object
- `code` is the HTTP status code of the response
- `data` is the data to send in the response (it must be a JSON serializable value)
- `descriptionOverride` is a field that you can use to give a custom string description of the response (by default it 
will be the description of the HTTP status code)
- `internalCode` is the internal code of the response (if you want to add a custom internal code to be more specific 
than the HTTP status code)


### Application to our use case

For the implementation we will split our code into four parts:
1. If we want to publish, we check first if it is possible (if the last pipeline status is `success`).
2. Change the project visibility on Gitlab.
3. Change the assignment published status on the Dojo database.
4. Send the response to the client.

```typescript
// Part 1
if ( publish ) {
    const isPublishable = await SharedAssignmentHelper.isPublishable(req.boundParams.assignment!.gitlabId);
    if ( !isPublishable.isPublishable ) {
        return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, { lastPipeline: isPublishable.lastPipeline }, isPublishable.status?.message, isPublishable.status?.code);
    }
}

try {
    // Part 2
    await GitlabManager.changeRepositoryVisibility(req.boundParams.assignment!.gitlabId, publish ? GitlabVisibility.INTERNAL : GitlabVisibility.PRIVATE);

    // Part 3
    await db.assignment.update({
        where: {
            name: req.boundParams.assignment!.name
        },
        data : {
            published: publish
        }
    });

    // Part 4
    req.session.sendResponse(res, StatusCodes.OK);
} catch ( error ) {
    if ( error instanceof AxiosError ) {
        res.status(error.response?.status ?? HttpStatusCode.InternalServerError).send();
        return;
    }

    logger.error(error);
    res.status(StatusCodes.INTERNAL_SERVER_ERROR).send();
}
```

## Documentation

Finally, the fun part. We need to document our new created routes. For that we use the [`OpenAPI` specification in his
3.1.0 version](https://express-validator.github.io/docs/). 

The documentation is yaml formatted and is in the `assets/OpenAPI/OpenAPI.yml` file.


## Use case: final code

### AssignmentRoutes.ts
```typescript
import { Express }                    from 'express-serve-static-core';
import express                        from 'express';
import { StatusCodes }                from 'http-status-codes';
import RoutesManager                  from '../express/RoutesManager';
import SecurityMiddleware             from '../middlewares/SecurityMiddleware';
import SecurityCheckType              from '../types/SecurityCheckType';
import GitlabManager                  from '../managers/GitlabManager';
import { AxiosError, HttpStatusCode } from 'axios';
import logger                         from '../shared/logging/WinstonLogger';
import db                             from '../helpers/DatabaseHelper';
import GitlabVisibility               from '../shared/types/Gitlab/GitlabVisibility';
import SharedAssignmentHelper         from '../shared/helpers/Dojo/SharedAssignmentHelper';


class AssignmentRoutes implements RoutesManager {
    registerOnBackend(backend: Express) {
        backend.patch('/assignments/:assignmentNameOrUrl/publish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.publishAssignment.bind(this));
        backend.patch('/assignments/:assignmentNameOrUrl/unpublish', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.unpublishAssignment.bind(this));
    }

    private async publishAssignment(req: express.Request, res: express.Response) {
        return this.changeAssignmentPublishedStatus(true)(req, res);
    }

    private async unpublishAssignment(req: express.Request, res: express.Response) {
        return this.changeAssignmentPublishedStatus(false)(req, res);
    }

    private changeAssignmentPublishedStatus(publish: boolean): (req: express.Request, res: express.Response) => Promise<void> {
        return async (req: express.Request, res: express.Response): Promise<void> => {
            if ( publish ) {
                const isPublishable = await SharedAssignmentHelper.isPublishable(req.boundParams.assignment!.gitlabId);
                if ( !isPublishable.isPublishable ) {
                    return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, { lastPipeline: isPublishable.lastPipeline }, isPublishable.status?.message, isPublishable.status?.code);
                }
            }

            try {
                await GitlabManager.changeRepositoryVisibility(req.boundParams.assignment!.gitlabId, publish ? GitlabVisibility.INTERNAL : GitlabVisibility.PRIVATE);

                await db.assignment.update({
                    where: {
                        name: req.boundParams.assignment!.name
                    },
                    data : {
                        published: publish
                    }
                });

                req.session.sendResponse(res, StatusCodes.OK);
            } catch ( error ) {
                if ( error instanceof AxiosError ) {
                    res.status(error.response?.status ?? HttpStatusCode.InternalServerError).send();
                    return;
                }

                logger.error(error);
                res.status(StatusCodes.INTERNAL_SERVER_ERROR).send();
            }
        };
    }
}

export default new AssignmentRoutes();
```

### src/types/express/index.d.ts
```typescript
import Session                  from '../../controllers/Session';
import { Assignment, Exercise } from '../DatabaseTypes';

// to make the file a module and avoid the TypeScript error
export {};

declare global {
    namespace Express {
        export interface Request {
            session: Session,
            boundParams: {
                assignment: Assignment | undefined, exercise: Exercise | undefined
            }
        }
    }
}
```

### ParamsCallbackManager.ts
```typescript
import { Express }       from 'express-serve-static-core';
import express           from 'express';
import { StatusCodes }   from 'http-status-codes';
import AssignmentManager from '../managers/AssignmentManager';

type GetFunction = (id: string | number, ...args: Array<unknown>) => Promise<unknown>

class ParamsCallbackManager {
    protected listenParam(paramName: string, backend: Express, getFunction: GetFunction, args: Array<unknown>, indexName: string) {
        backend.param(paramName, (req: express.Request, res: express.Response, next: express.NextFunction, id: string | number) => {
            getFunction(id, ...args).then(result => {
                if ( result ) {
                    this.initBoundParams(req);
                    (req.boundParams as Record<string, unknown>)[indexName] = result;

                    next();
                } else {
                    req.session.sendResponse(res, StatusCodes.NOT_FOUND, {}, 'Param bounding failed: ' + paramName);
                }
            });
        });
    }

    initBoundParams(req: express.Request) {
        if ( !req.boundParams ) {
            req.boundParams = {
                assignment: undefined,
                exercise  : undefined
            };
        }
    }

    registerOnBackend(backend: Express) {
        this.listenParam('assignmentNameOrUrl', backend, (AssignmentManager.get as GetFunction).bind(AssignmentManager), [ {
            exercises: true,
            staff    : true
        } ], 'assignment');

        ...
    }
}

export default new ParamsCallbackManager();
```
 No newline at end of file
+2 −16
Original line number Diff line number Diff line
# Documentation of the `dojo` API
# Documentation of `The Dojo Backend API`

In this wiki you will find the documentation related to the `dojo` API.

## Dojo Project
The dojo platform is an online tool built to help practice programming by allowing users to propose assignments and perform them as exercises.

The two major concepts of the platform are the **assignments** (provided by teaching staff) and the **exercises** (performed by students).

More details here : [Dojo detailed presentation](0-Dojo-presentation)


## Development / Contribution

* [How to contribute]() - Available soon
* [How to setup your development environment](Development/1-How-to-setup-your-development-environment)
* [How to add a new route](Development/2-How-to-add-a-new-route)
 No newline at end of file
All documentations are available on the [Dojo documentation website](https://www.hepiapp.ch/) : https://www.hepiapp.ch/.
 No newline at end of file
+3 −0
Original line number Diff line number Diff line
sonar.projectKey=DojoBackendAPI
sonar.qualitygate.wait=true
sonar.exclusions=ExpressAPI/prisma/seed.ts
 No newline at end of file