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'; 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', SecurityMiddleware.check(true, SecurityCheckType.ADMIN), this.getAllExercises.bind(this) as RequestHandler); backend.get('/exercises/:exerciseIdOrUrl/assignment', SecurityMiddleware.check(false, SecurityCheckType.EXERCISE_SECRET), this.getAssignment.bind(this) as RequestHandler); backend.get('/exercises/:exerciseIdOrUrl/members', SecurityMiddleware.check(true, SecurityCheckType.ADMIN, SecurityCheckType.EXERCISE_MEMBERS), this.getExerciseMembers.bind(this) as RequestHandler); backend.get('/exercises/:exerciseIdOrUrl/results', SecurityMiddleware.check(true, SecurityCheckType.ADMIN, SecurityCheckType.EXERCISE_MEMBERS), this.getExerciseResults.bind(this) as RequestHandler); backend.delete('/exercises/:exerciseIdOrUrl', SecurityMiddleware.check(true, SecurityCheckType.ADMIN, SecurityCheckType.EXERCISE_MEMBERS), this.deleteExercise.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); } 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 async getExerciseMembers(req: express.Request, res: express.Response) { const repoId = req.boundParams.exercise!.gitlabId; const members = await GitlabManager.getRepositoryMembers(String(repoId)); return req.session.sendResponse(res, StatusCodes.OK, members); } private async getExerciseResults(req: express.Request, res: express.Response) { const results = await db.result.findMany({ where : { exerciseId: req.boundParams.exercise!.id }, orderBy: { dateTime: 'desc' } }); return req.session.sendResponse(res, StatusCodes.OK, results); } private async deleteExercise(req: express.Request, res: express.Response) { const repoId = req.boundParams.exercise!.gitlabId; const members = await GitlabManager.getRepositoryMembers(String(repoId), false); for ( const member of members ) { if ( member.id !== Config.gitlab.account.id ) { await GitlabManager.deleteRepositoryMember(repoId, member.id); } } await GitlabManager.moveRepository(repoId, Config.gitlab.group.deletedExercises); await db.exercise.update({ where: { id: req.boundParams.exercise!.id }, data : { deleted: true } }); return req.session.sendResponse(res, StatusCodes.OK); } private getExercisePath(assignment: Assignment, exerciseId: string): string { return `dojo-ex_${ (assignment.gitlabLastInfo as unknown as Gitlab.ProjectSchema).path }_${ exerciseId }`; } // Get all exercise private async getAllExercises(req: express.Request, res: express.Response) { const exos = await db.exercise.findMany(); return req.session.sendResponse(res, StatusCodes.OK, exos); } 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); } } export default new ExerciseRoutes();