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 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.js'; import { Assignment, Exercise } 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.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'; import SharedConfig from '../shared/config/SharedConfig.js'; class AssignmentRoutes implements RoutesManager { private readonly assignmentValidator: ExpressValidator.Schema = { name : { trim : true, notEmpty: true }, members : { trim : true, notEmpty : true, customSanitizer: DojoValidators.jsonSanitizer }, template: { trim : true, custom : DojoValidators.templateUrlValidator, customSanitizer: DojoValidators.templateUrlSanitizer } }; private readonly assignmentAddCorrigeValidator: ExpressValidator.Schema = { exerciseIdOrUrl: { trim : true, notEmpty: true, custom : DojoValidators.exerciseIdOrUrlValidator } }; registerOnBackend(backend: Express) { 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) 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) 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 private async getAssignment(req: express.Request, res: express.Response) { const assignment: Partial<Assignment> | undefined = req.boundParams.assignment; if ( assignment ) { if ( !assignment.published && !await AssignmentManager.isUserAllowedToAccessAssignment(assignment as Assignment, req.session.profile) ) { delete assignment.gitlabId; delete assignment.gitlabLink; delete assignment.gitlabCreationInfo; delete assignment.gitlabLastInfo; delete assignment.gitlabLastInfoDate; delete assignment.staff; delete assignment.exercises; } const getExercises = req.query.getMyExercises; let exercises: Array<Omit<Exercise, 'assignment'>> = []; if ( getExercises ) { exercises = await db.exercise.findMany({ where : { assignmentName: assignment.name, members : { some: { id: req.session.profile.id } } }, include: { assignment: false, members : true, results : true } }); } return req.session.sendResponse(res, StatusCodes.OK, DojoModelsHelper.getFullSerializableObject(Object.assign(assignment, { myExercises: exercises }))); } else { return res.status(StatusCodes.NOT_FOUND).send(); } } private async createAssignment(req: express.Request, res: express.Response) { const params: { 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: 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(JSON.stringify(error)); 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; } } req.session.sendResponse(res, error.cause?.response.status ?? StatusCodes.INTERNAL_SERVER_ERROR); return; } req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR); return; } await new Promise(resolve => setTimeout(resolve, Config.gitlab.repository.timeoutAfterCreation)); const repoCreationFnExec = GlobalHelper.repoCreationFnExecCreator(req, res, DojoStatusCode.ASSIGNMENT_CREATION_GITLAB_ERROR, DojoStatusCode.ASSIGNMENT_CREATION_INTERNAL_ERROR, repository); try { 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'); await repoCreationFnExec(() => Promise.all(params.members.map(member => member.id).map(GlobalHelper.addRepoMember(repository.id))), 'Add repository members error'); const assignment: Assignment = await repoCreationFnExec(() => db.assignment.create({ data: { name : repository.name, gitlabId : repository.id, gitlabLink : repository.web_url, gitlabCreationInfo: repository as unknown as Prisma.JsonObject, gitlabLastInfo : repository as unknown as Prisma.JsonObject, gitlabLastInfoDate: new Date(), staff : { connectOrCreate: [ ...params.members.map(gitlabUser => { return { create: { id : gitlabUser.id, gitlabUsername: gitlabUser.name }, where : { id: gitlabUser.id } }; }) ] } } }), 'Database error') as Assignment; req.session.sendResponse(res, StatusCodes.OK, assignment); } catch ( error ) { /* Empty */ } } 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 ) { 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 ? 'internal' : 'private'); await db.assignment.update({ where: { name: req.boundParams.assignment!.name }, data : { published: publish } }); req.session.sendResponse(res, StatusCodes.OK); } catch ( error ) { 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; } 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 ) { return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, undefined, 'The exercise does not belong to the assignment', DojoStatusCode.ASSIGNMENT_EXERCISE_NOT_RELATED); } if ( !req.boundParams.assignment?.published ) { return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, undefined, 'The assignment must be public', DojoStatusCode.ASSIGNMENT_NOT_PUBLISHED); } if ( !isUpdate && req.boundParams.exercise?.isCorrection ) { return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, undefined, 'This exercise is already a correction', DojoStatusCode.EXERCISE_CORRECTION_ALREADY_EXIST); } else if ( isUpdate && !req.boundParams.exercise?.isCorrection ) { return req.session.sendResponse(res, StatusCodes.BAD_REQUEST, undefined, 'This exercise is not a correction', DojoStatusCode.EXERCISE_CORRECTION_NOT_EXIST); } const lastCommit = await GitlabManager.getRepositoryLastCommit(req.boundParams.exercise!.gitlabId); if ( lastCommit ) { if ( !isUpdate && SharedConfig.production ) { //Disable in dev env because gitlab dev group is private and we can't change visibility of sub projects await GitlabManager.changeRepositoryVisibility(req.boundParams.exercise!.gitlabId, 'internal'); } await db.exercise.update({ where: { id: req.boundParams.exercise!.id }, data : { correctionCommit: lastCommit } }); return req.session.sendResponse(res, StatusCodes.OK); } else { return req.session.sendResponse(res, StatusCodes.INTERNAL_SERVER_ERROR, undefined, 'No last commit found'); } }; } } export default new AssignmentRoutes();