Skip to content
Snippets Groups Projects
Select Git revision
  • e071be9ae0ed3b682754d59a4b1afc1e1e4b19f8
  • main default protected
  • add_export_route
  • add_route_assignments
  • 4.1.0-dev
  • 4.0.0
  • 3.5.3
  • 3.5.3-dev
  • 3.5.2
  • 3.5.2-dev
  • 3.5.1
  • 3.5.1-dev
  • 3.5.0
  • 3.4.2
  • 3.4.1
  • 3.4.0
  • 3.3.0
  • 3.2.0
  • 3.1.3
  • 3.1.2
  • 3.1.1
  • 3.1.0
  • 3.0.1
  • 3.0.0
24 results

ExerciseRoutes.ts

Blame
  • Forked from Dojo Project (HES-SO) / Projects / Backend / DojoBackendAPI
    Source project has a limited visibility.
    ExerciseRoutes.ts 16.45 KiB
    import { Express }                 from 'express-serve-static-core';
    import express, { RequestHandler } from 'express';
    import * as ExpressValidator       from 'express-validator';
    import { StatusCodes }             from 'http-status-codes';
    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 { Prisma }                  from '@prisma/client';
    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.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';
    import archiver from 'archiver';
    
    
    class ExerciseRoutes implements RoutesManager {
        private readonly exerciseValidator: ExpressValidator.Schema = {
            members: {
                trim           : true,
                notEmpty       : true,
                customSanitizer: DojoValidators.jsonSanitizer
            }
        };
        
        private readonly resultValidator: ExpressValidator.Schema = {
            exitCode     : {
                isInt: true,
                toInt: true
            },
            commit       : {
                trim           : true,
                notEmpty       : true,
                customSanitizer: DojoValidators.jsonSanitizer
            },
            results      : {
                trim           : true,
                notEmpty       : true,
                custom         : DojoValidators.exerciseResultsValidator,
                customSanitizer: DojoValidators.jsonSanitizer
            },
            files        : {
                trim           : true,
                notEmpty       : true,
                customSanitizer: DojoValidators.jsonSanitizer
            },
            archiveBase64: {
                isBase64: true,
                notEmpty: true
            }
        };
        
        registerOnBackend(backend: Express) {
            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) as RequestHandler);
            
            backend.post('/exercises/:exerciseIdOrUrl/results', SecurityMiddleware.check(false, SecurityCheckType.EXERCISE_SECRET), ParamsValidatorMiddleware.validate(this.resultValidator), this.createResult.bind(this) as RequestHandler);
            backend.get('/exercises/:exerciseIdOrUrl/export/:folderName', SecurityMiddleware.check(true, SecurityCheckType.ASSIGNMENT_STAFF), this.exportLightExercise.bind(this) as RequestHandler);
            backend.get('/exercises/:folderName/zip', SecurityMiddleware.check(false, SecurityCheckType.ASSIGNMENT_STAFF), this.zipExercise.bind(this) as RequestHandler);
        }
        
        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 Gitlab.ProjectSchema).path }_${ exerciseId }`;
        }
        
        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<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);
                    }
                }
            }
            
            return reachedLimitUsers;
        }
        
        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 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(JSON.stringify(error));
                    
                    if ( error instanceof GitbeakerRequestError && error.cause?.description ) {
                        const description = error.cause.description as unknown;
                        if ( GlobalHelper.isRepoNameAlreadyTaken(description) ) {
                            suffix++;
                        } else {
                            req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, {}, 'Unknown gitlab error while forking repository', DojoStatusCode.EXERCISE_CREATION_GITLAB_ERROR);
                            return undefined;
                        }
                    } else {
                        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 );
            
            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 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 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');
                
                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 repoCreationFnExec(() => db.exercise.create({
                    data: {
                        id                : exerciseId,
                        assignmentName    : assignment.name,
                        name              : repository.name,
                        secret            : secret,
                        gitlabId          : repository.id,
                        gitlabLink        : repository.web_url,
                        gitlabCreationInfo: repository as unknown as Prisma.JsonObject,
                        gitlabLastInfo    : repository as unknown as Prisma.JsonObject,
                        gitlabLastInfoDate: new Date(),
                        members           : {
                            connectOrCreate: [ ...params.members.map(gitlabUser => {
                                return {
                                    create: {
                                        id            : gitlabUser.id,
                                        gitlabUsername: gitlabUser.name
                                    },
                                    where : {
                                        id: gitlabUser.id
                                    }
                                };
                            }) ]
                        }
                    }
                })) as Exercise;
                
                req.session.sendResponse(res, StatusCodes.OK, exercise);
                return;
            } catch ( error ) {
                /* Empty */
            }
        }
        
        private async getAssignment(req: express.Request, res: express.Response) {
            const repoTree: Array<Gitlab.RepositoryTreeSchema> = await GitlabManager.getRepositoryTree(req.boundParams.exercise!.assignment.gitlabId);
            
            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 ) {
                    assignmentHjsonFile = file;
                }
                
                return file;
            }));
            
            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.valueOf() ) {
                    for ( const immutablePath of immutablePaths ) {
                        if ( gitlabTreeFile.path.startsWith(immutablePath) ) {
                            immutableFiles.push(await GitlabManager.getFile(req.boundParams.exercise!.assignment.gitlabId, gitlabTreeFile.path));
                            break;
                        }
                    }
                }
            }));
            
            return req.session.sendResponse(res, StatusCodes.OK, {
                assignment    : (req.boundParams.exercise as Exercise).assignment,
                assignmentFile: dojoAssignmentFile,
                immutable     : immutableFiles
            });
        }
        
        private async createResult(req: express.Request, res: express.Response) {
            const params: { exitCode: number, commit: Record<string, string>, results: ExerciseResultsFile, files: Array<IFileDirStat>, archiveBase64: string } = req.body;
            const exercise: Exercise = req.boundParams.exercise!;
            
            const result = await db.result.create({
                data: {
                    exerciseId: exercise.id,
                    exitCode  : params.exitCode,
                    success   : params.results.success!,
                    commit    : params.commit,
                    results   : params.results as unknown as Prisma.JsonObject,
                    files     : params.files
                }
            });
            
            fs.writeFileSync(path.join(Config.getResultsFolder(exercise), `${ result.dateTime.toISOString().replace(/:/g, '_') }.tar.gz`), params.archiveBase64, 'base64');
            
            req.session.sendResponse(res, StatusCodes.OK);
        }
        
        private async exportLightExercise(req: express.Request, res: express.Response) {
            try {
                const folderName = req.params.folderName;
                const parentDir = path.join('/tmp', folderName);
                fs.mkdirSync(parentDir, { recursive: true });
                
                const archive = await GitlabManager.archiveRepository(req.boundParams.exercise!.gitlabId);
                const buffer = Buffer.from(await archive.arrayBuffer());
                const zipName = req.boundParams.exercise?.name.replace(/ /g, "_") + '.tar.gz';
                const zipPath = path.join(parentDir, zipName);
                
                fs.writeFile(zipPath, buffer, (err) => {
                    if (err) {
                        console.error('Error saving archive:', err);
                    } else {
                        console.log(`Archive ${zipName} saved successfully!`);
                    }
                });
                
                const finalZipPath = path.join('/tmp', `${folderName}.zip`);
                const output = fs.createWriteStream(finalZipPath);
                const archiveZip = archiver('zip', {
                    zlib: { level: 9 } // Compression maximale
                });
                
                archiveZip.pipe(output);
                archiveZip.directory(parentDir, false);
                await archiveZip.finalize();
                res.download(finalZipPath, (err) => {
                    if (err) {
                        console.error('Error sending zip:', err);
                        res.status(500).send('Error sending zip');
                    } else {
                        console.log('Zip sent successfully!');
                    }
                });
            } catch (error) {
                console.error('Error exporting assignment:', error);
                res.status(500).send('Error exporting assignment');
            }
        }
        
        private async zipExercise(req : express.Request, res : express.Response) {
            const folderName = req.params.folderName;
            const zipPath = path.join('/tmp', `${folderName}.zip`);
            res.download(zipPath, (err) => {
                if (err) {
                    logger.error(`Error sending the zip : ${err}`);
                } else {
                    console.log("Zip sent successfully");
                }
            });
        }
    }
    
    
    export default new ExerciseRoutes();