diff --git a/.env-template b/.env-template new file mode 100644 index 00000000..1b18f9ec --- /dev/null +++ b/.env-template @@ -0,0 +1,13 @@ +client-secret= +client-id= +tenant-id= +PGU= +PGP= +POSTGRES_USER=${PGU} +URI= +EXPRESS_SESSION_SECRET= +PORT= +ENVIRONMENT= +DB_HOST= +DB_PORT= +DB_NAME= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9d406eb9..cdae00ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +backend/web-bff/App/.env.dev + HELP.md .gradle build/ @@ -48,3 +50,5 @@ docker.env ./startBackend.sh startBackend.sh +/.env +backend/web-bff/App/.env diff --git a/backend/app/src/main/java/com/ugent/pidgeon/GlobalErrorHandler.java b/backend/app/src/main/java/com/ugent/pidgeon/GlobalErrorHandler.java index 47371b60..6e5b68b9 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/GlobalErrorHandler.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/GlobalErrorHandler.java @@ -51,6 +51,15 @@ public ResponseEntity handleNoHandlerFoundException(HttpServlet "Resource/endpoint doesn't exist", path)); } + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException(HttpServletRequest request, Exception ex) { + logError(ex); + String path = request.getRequestURI(); + HttpStatus status = HttpStatus.NOT_FOUND; + return ResponseEntity.status(status).body(new ApiErrorReponse(OffsetDateTime.now(), status.value(), status.getReasonPhrase(), + "Resource/endpoint doesn't exist", path)); + } + /* Gets thrown when the method is not allowed */ @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity handleMethodNotSupportedException(HttpServletRequest request, Exception ex) { diff --git a/backend/app/src/main/java/com/ugent/pidgeon/auth/JwtAuthenticationFilter.java b/backend/app/src/main/java/com/ugent/pidgeon/auth/JwtAuthenticationFilter.java index a4ba6cfc..1ad279e6 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/auth/JwtAuthenticationFilter.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/auth/JwtAuthenticationFilter.java @@ -83,6 +83,7 @@ public void doFilterInternal(HttpServletRequest request, HttpServletResponse res String lastName; String email; String oid; + String studentnumber; String version = jwt.getClaim("ver").asString(); @@ -92,21 +93,21 @@ public void doFilterInternal(HttpServletRequest request, HttpServletResponse res lastName = jwt.getClaim("family_name").asString(); email = jwt.getClaim("unique_name").asString(); oid = jwt.getClaim("oid").asString(); + studentnumber = jwt.getClaim("ugentStudentID").asString(); } else if (version.startsWith("2.0")) { displayName = jwt.getClaim("name").asString(); lastName = jwt.getClaim("surname").asString(); firstName = displayName.replace(lastName, "").strip(); email = jwt.getClaim("mail").asString(); oid = jwt.getClaim("oid").asString(); + studentnumber = jwt.getClaim("ugentStudentID").asString(); } else { throw new JwkException("Invalid OAuth version"); } // print full object - // logger.info(jwt.getClaims()); + logger.info(jwt.getClaims()); - - - User user = new User(displayName, firstName,lastName, email, oid); + User user = new User(displayName, firstName,lastName, email, oid, studentnumber); Auth authUser = new Auth(user, new ArrayList<>()); SecurityContextHolder.getContext().setAuthentication(authUser); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/auth/RolesInterceptor.java b/backend/app/src/main/java/com/ugent/pidgeon/auth/RolesInterceptor.java index 17952d89..b3f5950a 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/auth/RolesInterceptor.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/auth/RolesInterceptor.java @@ -61,7 +61,7 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons if(userEntity == null) { System.out.println("User does not exist, creating new one. user_id: " + auth.getOid()); - userEntity = new UserEntity(auth.getUser().firstName,auth.getUser().lastName, auth.getEmail(), UserRole.student, auth.getOid()); + userEntity = new UserEntity(auth.getUser().firstName,auth.getUser().lastName, auth.getEmail(), UserRole.student, auth.getOid(), auth.getStudentNumber()); OffsetDateTime now = OffsetDateTime.now(); userEntity.setCreatedAt(now); userEntity = userRepository.save(userEntity); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/config/WebConfig.java b/backend/app/src/main/java/com/ugent/pidgeon/config/WebConfig.java index 0009ac5d..ea2dc330 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/config/WebConfig.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/config/WebConfig.java @@ -23,7 +23,9 @@ public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedMethods("*") .allowedOrigins("*") + .exposedHeaders("Content-Disposition") .allowedHeaders("*"); + } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/ClusterController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/ClusterController.java index 6508be3c..36a42280 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/ClusterController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/ClusterController.java @@ -65,10 +65,15 @@ public ResponseEntity getClustersForCourse(@PathVariable("courseid") Long cou if (checkResult.getStatus() != HttpStatus.OK) { return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); } + + CourseRelation courseRelation = checkResult.getData().getSecond(); + boolean hideStudentNumber = courseRelation.equals(CourseRelation.enrolled); + // Get the clusters for the course List clusters = groupClusterRepository.findClustersWithoutInvidualByCourseId(courseid); List clusterJsons = clusters.stream().map( - entityToJsonConverter::clusterEntityToClusterJson).toList(); + g -> entityToJsonConverter.clusterEntityToClusterJson(g, hideStudentNumber) + ).toList(); // Return the clusters return ResponseEntity.ok(clusterJsons); } @@ -107,20 +112,21 @@ public ResponseEntity createClusterForCourse(@PathVariable("courseid") Long c clusterJson.groupCount() ); cluster.setCreatedAt(OffsetDateTime.now()); + cluster.setLockGroupsAfter(clusterJson.lockGroupsAfter()); GroupClusterEntity clusterEntity = groupClusterRepository.save(cluster); for (int i = 0; i < clusterJson.groupCount(); i++) { groupRepository.save(new GroupEntity("Group " + (i + 1), cluster.getId())); } - GroupClusterJson clusterJsonResponse = entityToJsonConverter.clusterEntityToClusterJson(clusterEntity); + GroupClusterJson clusterJsonResponse = entityToJsonConverter.clusterEntityToClusterJson(clusterEntity, false); // Return the cluster return ResponseEntity.status(HttpStatus.CREATED).body(clusterJsonResponse); } /** - * Returns all groups for a cluster + * Get cluster by ID * * @param clusterid identifier of a cluster * @param auth authentication object of the requesting user @@ -138,8 +144,11 @@ public ResponseEntity getCluster(@PathVariable("clusterid") Long clusterid, A return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); } GroupClusterEntity cluster = checkResult.getData(); + + CheckResult courseAdmin = courseUtil.getCourseIfAdmin(cluster.getCourseId(), auth.getUserEntity()); + boolean hideStudentNumber = !courseAdmin.getStatus().equals(HttpStatus.OK); // Return the cluster - return ResponseEntity.ok(entityToJsonConverter.clusterEntityToClusterJson(cluster)); + return ResponseEntity.ok(entityToJsonConverter.clusterEntityToClusterJson(cluster, hideStudentNumber)); } @@ -174,8 +183,9 @@ public ResponseEntity doGroupClusterUpdate(GroupClusterEntity clusterEntity, } clusterEntity.setMaxSize(clusterJson.getCapacity()); clusterEntity.setName(clusterJson.getName()); + clusterEntity.setLockGroupsAfter(clusterJson.getLockGroupsAfter()); clusterEntity = groupClusterRepository.save(clusterEntity); - return ResponseEntity.ok(entityToJsonConverter.clusterEntityToClusterJson(clusterEntity)); + return ResponseEntity.ok(entityToJsonConverter.clusterEntityToClusterJson(clusterEntity, false)); } /** @@ -183,9 +193,9 @@ public ResponseEntity doGroupClusterUpdate(GroupClusterEntity clusterEntity, * * @param clusterid identifier of a cluster * @param auth authentication object of the requesting user - * @param clusterFillMap Map object containing a map of all groups and their - * members of that cluster + * @param clusterFillMap Map object containing a map of all groups and their members of that cluster * @return ResponseEntity + * @ApiDog apiDog documentation * @HttpMethod PUT * @ApiPath /api/clusters/{clusterid}/fill * @AllowedRoles student, teacher @@ -226,14 +236,25 @@ public ResponseEntity fillCluster(@PathVariable("clusterid") Long clusterid, groupCluster.setGroupAmount(clusterFillJson.getClusterGroupMembers().size()); groupClusterRepository.save(groupCluster); - return ResponseEntity.status(HttpStatus.OK).body(entityToJsonConverter.clusterEntityToClusterJson(groupCluster)); + return ResponseEntity.status(HttpStatus.OK).body(entityToJsonConverter.clusterEntityToClusterJson(groupCluster, false)); } catch (Exception e) { Logger.getGlobal().severe(e.getMessage()); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Something went wrong"); } } - + /** + * Updates a cluster + * + * @param clusterid identifier of a cluster + * @param auth authentication object of the requesting user + * @param clusterJson ClusterJson object containing the cluster data + * @return ResponseEntity + * @ApiDog apiDog documentation + * @HttpMethod PATCH + * @ApiPath /api/clusters/{clusterid} + * @AllowedRoles student, teacher + */ @PatchMapping(ApiRoutes.CLUSTER_BASE_PATH + "/{clusterid}") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity patchCluster(@PathVariable("clusterid") Long clusterid, Auth auth, @RequestBody GroupClusterUpdateJson clusterJson) { @@ -253,6 +274,10 @@ public ResponseEntity patchCluster(@PathVariable("clusterid") Long clusterid, clusterJson.setName(cluster.getName()); } + if (clusterJson.getLockGroupsAfter() == null) { + clusterJson.setLockGroupsAfter(cluster.getLockGroupsAfter()); + } + return doGroupClusterUpdate(cluster, clusterJson); } @@ -317,6 +342,6 @@ public ResponseEntity createGroupForCluster(@PathVariable("clusterid") Long c cluster.setGroupAmount(cluster.getGroupAmount() + 1); groupClusterRepository.save(cluster); - return ResponseEntity.status(HttpStatus.CREATED).body(entityToJsonConverter.groupEntityToJson(group)); + return ResponseEntity.status(HttpStatus.CREATED).body(entityToJsonConverter.groupEntityToJson(group, false)); } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/CourseController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/CourseController.java index fa912b5f..4c61cd39 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/CourseController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/CourseController.java @@ -185,6 +185,18 @@ public ResponseEntity updateCourse(@RequestBody CourseJson courseJson, @PathV } } + /** + * Function to update a course + * + * @param courseJson JSON object containing the course name and description + * @param courseId ID of the course to update + * @param auth authentication object of the requesting user + * @return ResponseEntity with the updated course entity + * @ApiDog apiDog documentation + * @HttpMethod PATCH + * @AllowedRoles teacher, student + * @ApiPath /api/courses/{courseId} + */ @PatchMapping(ApiRoutes.COURSE_BASE_PATH + "/{courseId}") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity patchCourse(@RequestBody CourseJson courseJson, @PathVariable long courseId, Auth auth) { @@ -417,7 +429,7 @@ public ResponseEntity joinCourse(Auth auth, @PathVariable Long courseId) { * @param auth authentication object of the requesting user * @param courseId ID of the course to get the join key from * @return ResponseEntity with a statuscode and a JSON object containing the course information - * @ApiDog apiDog documentation + * @ApiDog apiDog documentation * @HttpMethod GET * @AllowedRoles teacher, student * @ApiPath /api/courses/{courseId}/join @@ -496,7 +508,7 @@ private ResponseEntity doRemoveFromCourse( * @param courseId ID of the course to add the user to * @param request JSON object containing the user id and relation * @return ResponseEntity with a statuscode and no body - * @ApiDog apiDog documentation + * @ApiDog apiDog documentation * @HttpMethod POST * @AllowedRoles teacher, admin, student * @ApiPath /api/courses/{courseId}/members @@ -591,6 +603,8 @@ public ResponseEntity getCourseMembers(Auth auth, @PathVariable Long courseId return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); } + boolean hideStudentNumber = checkResult.getData().getSecond().equals(CourseRelation.enrolled); + List members = courseUserRepository.findAllMembers(courseId); List memberJson = members.stream(). map(cue -> { @@ -598,7 +612,7 @@ public ResponseEntity getCourseMembers(Auth auth, @PathVariable Long courseId if (user == null) { return null; } - return entityToJsonConverter.userEntityToUserReferenceWithRelation(user, cue.getRelation()); + return entityToJsonConverter.userEntityToUserReferenceWithRelation(user, cue.getRelation(), hideStudentNumber); }). filter(Objects::nonNull).toList(); @@ -678,6 +692,17 @@ public ResponseEntity deleteCourseKey(Auth auth, @PathVariable Long cour return ResponseEntity.ok(""); } + /** + * Function to copy a course + * + * @param courseId ID of the course to copy + * @param auth authentication object of the requesting user + * @return ResponseEntity with the copied course entity + * @ApiDog apiDog documentation + * @HttpMethod POST + * @AllowedRoles teacher + * @ApiPath /api/courses/{courseId}/copy + */ @PostMapping(ApiRoutes.COURSE_BASE_PATH + "/{courseId}/copy") @Roles({UserRole.teacher}) @Transactional diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupController.java index bdae9bec..141fb9ff 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupController.java @@ -9,7 +9,9 @@ import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.GroupRepository; import com.ugent.pidgeon.util.CheckResult; +import com.ugent.pidgeon.util.ClusterUtil; import com.ugent.pidgeon.util.CommonDatabaseActions; +import com.ugent.pidgeon.util.CourseUtil; import com.ugent.pidgeon.util.EntityToJsonConverter; import com.ugent.pidgeon.util.GroupUtil; import org.springframework.beans.factory.annotation.Autowired; @@ -28,13 +30,21 @@ public class GroupController { private EntityToJsonConverter entityToJsonConverter; @Autowired private CommonDatabaseActions commonDatabaseActions; + @Autowired + private ClusterUtil clusterUtil; + @Autowired + private CourseUtil courseUtil; /** * Function to get a group by its identifier - * @param groupid - * @param auth - * @return + * @param groupid identifier of a group + * @param auth authentication object of the requesting user + * @return ResponseEntity + * @ApiDog apiDog documentation + * @HttpMethod GET + * @AllowedRoles student, teacher + * @ApiPath /api/groups/{groupid} */ @GetMapping(ApiRoutes.GROUP_BASE_PATH + "/{groupid}") @Roles({UserRole.student, UserRole.teacher}) @@ -50,8 +60,14 @@ public ResponseEntity getGroupById(@PathVariable("groupid") Long groupid, Aut return ResponseEntity.status(checkResult1.getStatus()).body(checkResult1.getMessage()); } + boolean hideStudentNumber = true; + CheckResult adminCheck = groupUtil.isAdminOfGroup(groupid, auth.getUserEntity()); + if (adminCheck.getStatus().equals(HttpStatus.OK)) { + hideStudentNumber = false; + } + // Return the group - GroupJson groupJson = entityToJsonConverter.groupEntityToJson(group); + GroupJson groupJson = entityToJsonConverter.groupEntityToJson(group, hideStudentNumber); return ResponseEntity.ok(groupJson); } @@ -63,7 +79,7 @@ public ResponseEntity getGroupById(@PathVariable("groupid") Long groupid, Aut * @param auth authentication object of the requesting user * @return ResponseEntity * @ApiDog apiDog documentation - * @HttpMethod Put + * @HttpMethod PUT * @AllowedRoles teacher * @ApiPath /api/groups/{groupid} */ @@ -81,7 +97,7 @@ public ResponseEntity updateGroupName(@PathVariable("groupid") Long groupid, * @param auth authentication object of the requesting user * @return ResponseEntity * @ApiDog apiDog documentation - * @HttpMethod Patch + * @HttpMethod PATCH * @AllowedRoles teacher * @ApiPath /api/groups/{groupid} */ @@ -113,7 +129,7 @@ private ResponseEntity doGroupNameUpdate(Long groupid, NameRequest nameReques groupRepository.save(group); // Return the updated group - GroupJson groupJson = entityToJsonConverter.groupEntityToJson(group); + GroupJson groupJson = entityToJsonConverter.groupEntityToJson(group, false); return ResponseEntity.ok(groupJson); } @@ -124,7 +140,7 @@ private ResponseEntity doGroupNameUpdate(Long groupid, NameRequest nameReques * @param auth authentication object of the requesting user * @return ResponseEntity * @ApiDog apiDog documentation - * @HttpMethod Delete + * @HttpMethod DELETE * @AllowedRoles teacher, student * @ApiPath /api/groups/{groupid} */ diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupFeedbackController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupFeedbackController.java index 49318700..54a52098 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupFeedbackController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupFeedbackController.java @@ -52,7 +52,7 @@ public class GroupFeedbackController { * @param auth authentication object of the requesting user * @return ResponseEntity * @ApiDog apiDog documentation - * @HttpMethod Patch + * @HttpMethod PATCH * @AllowedRoles teacher, student * @ApiPath /api/projects/{projectid}/groups/{groupid}/score */ @@ -83,6 +83,18 @@ public ResponseEntity updateGroupScore(@PathVariable("groupid") long groupId, return doGroupFeedbackUpdate(groupFeedbackEntity, request); } + /** + * Function to delete the score of a group + * + * @param groupId identifier of a group + * @param projectId identifier of a project + * @param auth authentication object of the requesting user + * @return ResponseEntity + * @ApiDog apiDog documentation + * @HttpMethod Delete + * @AllowedRoles teacher, student + * @ApiPath /api/projects/{projectid}/groups/{groupid}/score + */ @DeleteMapping(ApiRoutes.GROUP_FEEDBACK_PATH) @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity deleteGroupScore(@PathVariable("groupid") long groupId, @PathVariable("projectid") long projectId, Auth auth) { @@ -99,6 +111,19 @@ public ResponseEntity deleteGroupScore(@PathVariable("groupid") long groupId, } } + /** + * Function to update the score of a group + * + * @param groupId identifier of a group + * @param projectId identifier of a project + * @param request request object containing the new score + * @param auth authentication object of the requesting user + * @return ResponseEntity + * @ApiDog apiDog documentation + * @HttpMethod PUT + * @AllowedRoles teacher, student + * @ApiPath /api/projects/{projectid}/groups/{groupid}/score + */ @PutMapping(ApiRoutes.GROUP_FEEDBACK_PATH) @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity updateGroupScorePut(@PathVariable("groupid") long groupId, @PathVariable("projectid") long projectId, @RequestBody UpdateGroupScoreRequest request, Auth auth) { @@ -136,8 +161,8 @@ public ResponseEntity doGroupFeedbackUpdate(GroupFeedbackEntity groupFeedback * @param request request object containing the new score * @param auth authentication object of the requesting user * @return ResponseEntity - * @ApiDog apiDog documentation - * @HttpMethod Post + * @ApiDog apiDog documentation + * @HttpMethod POST * @AllowedRoles teacher, student * @ApiPath /api/groups/{groupid}/projects/{projectid}/feedback */ @@ -174,7 +199,7 @@ public ResponseEntity addGroupScore(@PathVariable("groupid") long groupId, @P * @param projectId identifier of a project * @param auth authentication object of the requesting user * @return ResponseEntity - * @ApiDog apiDog documentation + * @ApiDog apiDog documentation * @HttpMethod Get * @AllowedRoles teacher, student * @ApiPath /api/projects/{projectid}/groups/{groupid}/score @@ -203,6 +228,17 @@ public ResponseEntity getGroupScore(@PathVariable("groupid") long groupI return ResponseEntity.ok(entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity)); } + /** + * Function to get the grades of a course + * + * @param courseId identifier of a course + * @param auth authentication object of the requesting user + * @return ResponseEntity + * @ApiDog apiDog documentation + * @HttpMethod Get + * @AllowedRoles teacher, student + * @ApiPath /api/courses/{courseId}/grades + */ @GetMapping(ApiRoutes.COURSE_BASE_PATH + "/{courseId}/grades") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity getCourseGrades(@PathVariable("courseId") long courseId, Auth auth) { diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupMemberController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupMemberController.java index a3b0161b..97d759ee 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupMemberController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/GroupMemberController.java @@ -66,7 +66,7 @@ public ResponseEntity removeMemberFromGroup(@PathVariable("groupid") lon * @param groupId ID of the group to remove the member from * @param auth authentication object of the requesting user * @return ResponseEntity with a string message about the operation result - * @ApiDog apiDog documentation + * @ApiDog apiDog documentation * @HttpMethod DELETE * @AllowedRoles teacher, student * @ApiPath /api/groups/{groupid}/members @@ -111,7 +111,9 @@ public ResponseEntity addMemberToGroup(@PathVariable("groupid") long gro try { groupMemberRepository.addMemberToGroup(groupId, memberid); List members = groupMemberRepository.findAllMembersByGroupId(groupId); - List response = members.stream().map(entityToJsonConverter::userEntityToUserReference).toList(); + List response = members.stream().map( + u -> entityToJsonConverter.userEntityToUserReference(u, false) + ).toList(); return ResponseEntity.ok(response); } catch (Exception e) { Logger.getGlobal().severe(e.getMessage()); @@ -126,7 +128,7 @@ public ResponseEntity addMemberToGroup(@PathVariable("groupid") long gro * @param groupId ID of the group to add the member to * @param auth authentication object of the requesting user * @return ResponseEntity with a list of UserJson objects containing the members of the group - * @ApiDog apiDog documentation + * @ApiDog apiDog documentation * @HttpMethod POST * @AllowedRoles teacher, student * @ApiPath /api/groups/{groupid}/members @@ -143,7 +145,9 @@ public ResponseEntity addMemberToGroupInferred(@PathVariable("groupid") try { groupMemberRepository.addMemberToGroup(groupId,user.getId()); List members = groupMemberRepository.findAllMembersByGroupId(groupId); - List response = members.stream().map(entityToJsonConverter::userEntityToUserReference).toList(); + List response = members.stream().map( + u -> entityToJsonConverter.userEntityToUserReference(u, true) + ).toList(); return ResponseEntity.ok(response); } catch (Exception e) { Logger.getGlobal().severe(e.getMessage()); @@ -171,8 +175,12 @@ public ResponseEntity findAllMembersByGroupId(@PathVariable("groupid") l return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); } + boolean hideStudentnumber = !groupUtil.isAdminOfGroup(groupId, user).getStatus().equals(HttpStatus.OK); + List members = groupMemberRepository.findAllMembersByGroupId(groupId); - List response = members.stream().map((UserEntity e) -> entityToJsonConverter.userEntityToUserReference(e)).toList(); + List response = members.stream().map( + (UserEntity e) -> entityToJsonConverter.userEntityToUserReference(e, hideStudentnumber)) + .toList(); return ResponseEntity.ok(response); } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/ProjectController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/ProjectController.java index 24bf639d..0193a4fe 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/ProjectController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/ProjectController.java @@ -10,6 +10,7 @@ import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.*; import com.ugent.pidgeon.util.*; +import java.time.OffsetDateTime; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -49,7 +50,7 @@ public class ProjectController { /** * Function to get all projects of a user * @param auth authentication object of the requesting user - * @ApiDog apiDog documentation + * @ApiDog apiDog documentation * @HttpMethod GET * @AllowedRoles teacher, student * @ApiPath /api/projects @@ -74,6 +75,10 @@ public ResponseEntity getProjects(Auth auth) { CourseRelation relation = courseCheck.getData().getSecond(); if (relation.equals(CourseRelation.enrolled)) { + if (project.getVisibleAfter() != null && project.getVisibleAfter().isBefore(OffsetDateTime.now())) { + project.setVisible(true); + projectRepository.save(project); + } if (project.isVisible()) { enrolledProjects.add(entityToJsonConverter.projectEntityToProjectResponseJsonWithStatus(project, course, user)); } @@ -113,6 +118,11 @@ public ResponseEntity getProjectById(@PathVariable Long projectId, Auth auth) } CourseEntity course = courseCheck.getData().getFirst(); CourseRelation relation = courseCheck.getData().getSecond(); + + if (project.getVisibleAfter() != null && project.getVisibleAfter().isBefore(OffsetDateTime.now())) { + project.setVisible(true); + projectRepository.save(project); + } if (!project.isVisible() && relation.equals(CourseRelation.enrolled)) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Project not found"); } @@ -161,6 +171,8 @@ public ResponseEntity createProject( projectJson.getGroupClusterId(), null, projectJson.isVisible(), projectJson.getMaxScore(), projectJson.getDeadline()); + project.setVisibleAfter(projectJson.getVisibleAfter()); + // Save the project entity ProjectEntity savedProject = projectRepository.save(project); CourseEntity courseEntity = checkAcces.getData(); @@ -180,6 +192,10 @@ private ResponseEntity doProjectUpdate(ProjectEntity project, ProjectJson pro project.setDeadline(projectJson.getDeadline()); project.setMaxScore(projectJson.getMaxScore()); project.setVisible(projectJson.isVisible()); + project.setVisibleAfter(projectJson.getVisibleAfter()); + if (project.getVisibleAfter() != null && project.getVisibleAfter().isBefore(OffsetDateTime.now())) { + project.setVisible(true); + } projectRepository.save(project); return ResponseEntity.ok(entityToJsonConverter.projectEntityToProjectResponseJson(project, courseRepository.findById(project.getCourseId()).get(), user)); } @@ -190,7 +206,7 @@ private ResponseEntity doProjectUpdate(ProjectEntity project, ProjectJson pro * @param projectJson ProjectUpdateDTO object containing the new project's information * @param auth authentication object of the requesting user * @ApiDog apiDog documentation - * @HttpMethod Put + * @HttpMethod PUT * @AllowedRoles teacher * @ApiPath /api/projects/{projectId} * @return ResponseEntity with the created project @@ -227,7 +243,7 @@ public ResponseEntity putProjectById(@PathVariable Long projectId, @RequestBo * @param projectJson ProjectUpdateDTO object containing the new project's information * @param auth authentication object of the requesting user * @ApiDog apiDog documentation - * @HttpMethod Patch + * @HttpMethod PATCH * @AllowedRoles teacher * @ApiPath /api/projects/{projectId} * @return ResponseEntity with the created project @@ -261,6 +277,10 @@ public ResponseEntity patchProjectById(@PathVariable Long projectId, @Request projectJson.setVisible(project.isVisible()); } + if (projectJson.getVisibleAfter() == null) { + projectJson.setVisibleAfter(project.getVisibleAfter()); + } + CheckResult checkProject = projectUtil.checkProjectJson(projectJson, project.getCourseId()); if (checkProject.getStatus() != HttpStatus.OK) { return ResponseEntity.status(checkProject.getStatus()).body(checkProject.getMessage()); @@ -292,11 +312,15 @@ public ResponseEntity getGroupsOfProject(@PathVariable Long projectId, Auth a "No groups for this project: use " + memberUrl + " to get the members of the course"); } + boolean hideStudentNumber; + CheckResult adminCheck = projectUtil.isProjectAdmin(projectId, auth.getUserEntity()); + hideStudentNumber = !adminCheck.getStatus().equals(HttpStatus.OK); + List groups = projectRepository.findGroupIdsByProjectId(projectId); List groupjsons = groups.stream() - .map((Long id) -> { - return groupRepository.findById(id).orElse(null); - }).filter(Objects::nonNull).map(entityToJsonConverter::groupEntityToJson).toList(); + .map((Long id) -> groupRepository.findById(id).orElse(null)).filter(Objects::nonNull).map( + g -> entityToJsonConverter.groupEntityToJson(g, hideStudentNumber)) + .toList(); return ResponseEntity.ok(groupjsons); } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java index 39bdca89..cf07c916 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java @@ -15,13 +15,25 @@ import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.*; import com.ugent.pidgeon.util.*; +import java.io.ByteArrayOutputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.logging.Level; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -89,6 +101,15 @@ public ResponseEntity getSubmission(@PathVariable("submissionid") long submis return ResponseEntity.ok(submissionJson); } + private Map> getLatestSubmissionsForProject(long projectId) { + List groupIds = projectRepository.findGroupIdsByProjectId(projectId); + return groupIds.stream() + .collect(Collectors.toMap( + groupId -> groupId, + groupId -> submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(projectId, groupId) + )); + } + /** * Function to get all submissions * @@ -109,35 +130,35 @@ public ResponseEntity getSubmissions(@PathVariable("projectid") long projecti return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); } - List projectGroupIds = projectRepository.findGroupIdsByProjectId(projectid); - List res = projectGroupIds.stream().map(groupId -> { - GroupEntity group = groupRepository.findById(groupId).orElse(null); + Map> submissions = getLatestSubmissionsForProject(projectid); + List res = new ArrayList<>(); + for (Map.Entry> entry : submissions.entrySet()) { + GroupEntity group = groupRepository.findById(entry.getKey()).orElse(null); if (group == null) { throw new RuntimeException("Group not found"); } - GroupJson groupjson = entityToJsonConverter.groupEntityToJson(group); - GroupFeedbackEntity groupFeedbackEntity = groupFeedbackRepository.getGroupFeedback(groupId, projectid); + GroupJson groupjson = entityToJsonConverter.groupEntityToJson(group, false); + GroupFeedbackEntity groupFeedbackEntity = groupFeedbackRepository.getGroupFeedback(entry.getKey(), projectid); GroupFeedbackJson groupFeedbackJson; if (groupFeedbackEntity == null) { groupFeedbackJson = null; } else { groupFeedbackJson = entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity); } - SubmissionEntity submission = submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(projectid, groupId).orElse(null); + SubmissionEntity submission = entry.getValue().orElse(null); if (submission == null) { - return new LastGroupSubmissionJson(null, groupjson, groupFeedbackJson); + res.add(new LastGroupSubmissionJson(null, groupjson, groupFeedbackJson)); + continue; } + res.add(new LastGroupSubmissionJson(entityToJsonConverter.getSubmissionJson(submission), groupjson, groupFeedbackJson)); + } - return new LastGroupSubmissionJson(entityToJsonConverter.getSubmissionJson(submission), groupjson, groupFeedbackJson); - - }).toList(); return ResponseEntity.ok(res); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); } } - /** * Function to submit a file * @@ -152,6 +173,7 @@ public ResponseEntity getSubmissions(@PathVariable("projectid") long projecti */ @PostMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/submit") //Route to submit a file, it accepts a multiform with the file and submissionTime + @Transactional @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity submitFile(@RequestParam("file") MultipartFile file, @PathVariable("projectid") long projectid, Auth auth) { long userId = auth.getUserEntity().getId(); @@ -161,7 +183,7 @@ public ResponseEntity submitFile(@RequestParam("file") MultipartFile file, @P return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); } - long groupId = checkResult.getData(); + Long groupId = checkResult.getData(); try { //Save the file entry in the database to get the id @@ -179,6 +201,7 @@ public ResponseEntity submitFile(@RequestParam("file") MultipartFile file, @P false ); submissionEntity.setDockerTestState(DockerTestState.finished); + submissionEntity.setDockerType(DockerTestType.NONE); //Save the submission in the database SubmissionEntity submission = submissionRepository.save(submissionEntity); @@ -186,7 +209,7 @@ public ResponseEntity submitFile(@RequestParam("file") MultipartFile file, @P //Save the file on the server String filename = file.getOriginalFilename(); Path path = Filehandler.getSubmissionPath(projectid, groupId, submission.getId()); - File savedFile = Filehandler.saveSubmission(path, file); + File savedFile = Filehandler.saveFile(path, file, Filehandler.SUBMISSION_FILENAME); String pathname = path.resolve(Filehandler.SUBMISSION_FILENAME).toString(); //Update name and path for the file entry @@ -201,6 +224,8 @@ public ResponseEntity submitFile(@RequestParam("file") MultipartFile file, @P Logger.getLogger("SubmissionController").info("no tests"); submission.setStructureFeedback("No specific structure requested for this project."); submission.setStructureAccepted(true); + submission.setDockerAccepted(true); + submissionRepository.save(submission); } else { // Check file structure @@ -237,7 +262,7 @@ public ResponseEntity submitFile(@RequestParam("file") MultipartFile file, @P try { // Check if docker tests succeed DockerSubmissionTestModel dockerModel = new DockerSubmissionTestModel(testEntity.getDockerImage()); - DockerOutput dockerOutput = testRunner.runDockerTest(new ZipFile(finalSavedFile), testEntity, artifactPath, dockerModel); + DockerOutput dockerOutput = testRunner.runDockerTest(new ZipFile(finalSavedFile), testEntity, artifactPath, dockerModel, projectid); if (dockerOutput == null) { throw new RuntimeException("Error while running docker tests."); } @@ -265,6 +290,7 @@ public ResponseEntity submitFile(@RequestParam("file") MultipartFile file, @P return ResponseEntity.ok(entityToJsonConverter.getSubmissionJson(submission)); } catch (Exception e) { + Logger.getLogger("SubmissionController").log(Level.SEVERE, e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body("Failed to save submissions on file server."); } @@ -297,24 +323,57 @@ public ResponseEntity getSubmissionFile(@PathVariable("submissionid") long su } // Get the file from the server - try { - Resource zipFile = Filehandler.getFileAsResource(Path.of(file.getPath())); - if (zipFile == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body("File not found."); + return Filehandler.getZipFileAsResponse(Path.of(file.getPath()), file.getName()); + } + + @GetMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/submissions/files") + @Roles({UserRole.teacher, UserRole.student}) + public ResponseEntity getSubmissionsFiles(@PathVariable("projectid") long projectid, @RequestParam(value = "artifacts", required = false) Boolean artifacts, Auth auth) { + try { + CheckResult checkResult = projectUtil.isProjectAdmin(projectid, auth.getUserEntity()); + if (!checkResult.getStatus().equals(HttpStatus.OK)) { + return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); + } + + Path tempDir = Files.createTempDirectory("SELAB6CANDELETEallsubmissions"); + Path mainZipPath = tempDir.resolve("main.zip"); + try (ZipOutputStream mainZipOut = new ZipOutputStream(Files.newOutputStream(mainZipPath))) { + Map> submissions = getLatestSubmissionsForProject(projectid); + for (Map.Entry> entry : submissions.entrySet()) { + SubmissionEntity submission = entry.getValue().orElse(null); + if (submission == null) { + continue; + } + FileEntity file = fileRepository.findById(submission.getFileId()).orElse(null); + if (file == null) { + continue; + } + + // Create the group-specific zip file in a temporary location + Path groupZipPath = tempDir.resolve("group-" + submission.getGroupId() + ".zip"); + try (ZipOutputStream groupZipOut = new ZipOutputStream(Files.newOutputStream(groupZipPath))) { + File submissionZip = Path.of(file.getPath()).toFile(); + Filehandler.addExistingZip(groupZipOut, "files.zip", submissionZip); + + if (artifacts != null && artifacts) { + Path artifactPath = Filehandler.getSubmissionArtifactPath(projectid, submission.getGroupId(), submission.getId()); + File artifactZip = artifactPath.toFile(); + if (artifactZip.exists()) { + Filehandler.addExistingZip(groupZipOut, "artifacts.zip", artifactZip); + } } - // Set headers for the response - HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + file.getName()); - headers.add(HttpHeaders.CONTENT_TYPE, "application/zip"); + } - return ResponseEntity.ok() - .headers(headers) - .body(zipFile); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); + Filehandler.addExistingZip(mainZipOut, "group-" + submission.getGroupId() + ".zip", groupZipPath.toFile()); } + } + + return Filehandler.getZipFileAsResponse(mainZipPath, "allsubmissions.zip"); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); } + } @GetMapping(ApiRoutes.SUBMISSION_BASE_PATH + "/{submissionid}/artifacts") //Route to get a submission @Roles({UserRole.teacher, UserRole.student}) @@ -396,4 +455,17 @@ public ResponseEntity getSubmissionsForGroup(@PathVariable("projectid") long List res = submissions.stream().map(entityToJsonConverter::getSubmissionJson).toList(); return ResponseEntity.ok(res); } + + @GetMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/adminsubmissions") + @Roles({UserRole.teacher, UserRole.student}) + public ResponseEntity getAdminSubmissions(@PathVariable("projectid") long projectid, Auth auth) { + CheckResult checkResult = projectUtil.isProjectAdmin(projectid, auth.getUserEntity()); + if (!checkResult.getStatus().equals(HttpStatus.OK)) { + return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); + } + + List submissions = submissionRepository.findAdminSubmissionsByProjectId(projectid); + List res = submissions.stream().map(entityToJsonConverter::getSubmissionJson).toList(); + return ResponseEntity.ok(res); + } } \ No newline at end of file diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/TestController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/TestController.java index 3fc8ea2d..8e4c2d01 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/TestController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/TestController.java @@ -9,9 +9,8 @@ import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.*; import com.ugent.pidgeon.util.*; +import java.io.File; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletableFuture; -import java.util.logging.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.http.*; @@ -19,9 +18,6 @@ import org.springframework.web.multipart.MultipartFile; import java.nio.file.Path; -import java.util.Optional; -import java.util.function.Function; - @RestController public class TestController { @@ -48,7 +44,7 @@ public class TestController { * @param projectId the id of the project to update the tests for * @param auth the authentication object of the requesting user * @HttpMethod POST - * @ApiDog apiDog documentation + * @ApiDog apiDog documentation * @AllowedRoles teacher * @ApiPath /api/projects/{projectid}/tests * @return ResponseEntity with the updated tests @@ -64,6 +60,16 @@ public ResponseEntity updateTests( testJson.getDockerTemplate(), testJson.getStructureTest(), HttpMethod.POST); } + /** + * Function to update the tests of a project + * @param projectId the id of the project to update the tests for + * @param auth the authentication object of the requesting user + * @HttpMethod PATCH + * @ApiDog apiDog documentation + * @AllowedRoles teacher + * @ApiPath /api/projects/{projectid}/tests + * @return ResponseEntity with the updated tests + */ @PatchMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity patchTests( @@ -75,6 +81,16 @@ public ResponseEntity patchTests( testJson.getDockerTemplate(), testJson.getStructureTest(), HttpMethod.PATCH); } + /** + * Function to update the tests of a project + * @param projectId the id of the project to update the tests for + * @param auth the authentication object of the requesting user + * @HttpMethod PUT + * @ApiDog apiDog documentation + * @AllowedRoles teacher + * @ApiPath /api/projects/{projectid}/tests + * @return ResponseEntity with the updated tests + */ @PutMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity putTests( @@ -112,7 +128,7 @@ private ResponseEntity alterTests( structureTemplate = null; } - CheckResult> updateCheckResult = testUtil.checkForTestUpdate(projectId, user, dockerImage, dockerScript, dockerTemplate, httpMethod); + CheckResult> updateCheckResult = testUtil.checkForTestUpdate(projectId, user, dockerImage, dockerScript, dockerTemplate, structureTemplate, httpMethod); if (!updateCheckResult.getStatus().equals(HttpStatus.OK)) { @@ -224,7 +240,7 @@ public ResponseEntity getTests(@PathVariable("projectid") long projectId, Aut @DeleteMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests") @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity deleteTestById(@PathVariable("projectid") long projectId, Auth auth) { - CheckResult> updateCheckResult = testUtil.checkForTestUpdate(projectId, auth.getUserEntity(), null, null, null, HttpMethod.DELETE); + CheckResult> updateCheckResult = testUtil.checkForTestUpdate(projectId, auth.getUserEntity(), null, null, null, null, HttpMethod.DELETE); if (!updateCheckResult.getStatus().equals(HttpStatus.OK)) { return ResponseEntity.status(updateCheckResult.getStatus()).body(updateCheckResult.getMessage()); } @@ -238,5 +254,128 @@ public ResponseEntity deleteTestById(@PathVariable("projectid") long projectI } return ResponseEntity.ok().build(); } + + /** + * Function to upload extra files for a test + * @param projectId the id of the project to upload the files for + * @param file the file to upload + * @param auth the authentication object of the requesting user + * @HttpMethod PUT + * @ApiDog apiDog documentation + * @AllowedRoles teacher, student + * @ApiPath /api/projects/{projectid}/tests/extrafiles + * @return ResponseEntity with the updated tests + */ + @PutMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests/extrafiles") + @Roles({UserRole.teacher, UserRole.student}) + public ResponseEntity uploadExtraTestFiles( + @PathVariable("projectid") long projectId, + @RequestParam("file") MultipartFile file, + Auth auth + ) { + CheckResult checkResult = testUtil.getTestIfAdmin(projectId, auth.getUserEntity()); + if (!checkResult.getStatus().equals(HttpStatus.OK)) { + return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); + } + + TestEntity testEntity = checkResult.getData(); + + try { + Path path = Filehandler.getTestExtraFilesPath(projectId); + Filehandler.saveFile(path, file, Filehandler.EXTRA_TESTFILES_FILENAME); + + FileEntity fileEntity = new FileEntity(); + fileEntity.setName(file.getOriginalFilename()); + fileEntity.setPath(path.resolve(Filehandler.EXTRA_TESTFILES_FILENAME).toString()); + fileEntity.setUploadedBy(auth.getUserEntity().getId()); + fileEntity = fileRepository.save(fileEntity); + + testEntity.setExtraFilesId(fileEntity.getId()); + testEntity = testRepository.save(testEntity); + + return ResponseEntity.ok(entityToJsonConverter.testEntityToTestJson(testEntity, projectId)); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error while saving files"); + } + } + + /** + * Function to delete extra files for a test + * @param projectId the id of the project to delete the files for + * @param auth the authentication object of the requesting user + * @HttpMethod DELETE + * @ApiDog apiDog documentation + * @AllowedRoles teacher, student + * @ApiPath /api/projects/{projectid}/tests/extrafiles + * @return ResponseEntity with the updated tests + */ + @DeleteMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests/extrafiles") + @Roles({UserRole.teacher, UserRole.student}) + public ResponseEntity deleteExtraTestFiles( + @PathVariable("projectid") long projectId, + Auth auth + ) { + CheckResult checkResult = testUtil.getTestIfAdmin(projectId, auth.getUserEntity()); + if (!checkResult.getStatus().equals(HttpStatus.OK)) { + return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); + } + + TestEntity testEntity = checkResult.getData(); + + try { + + FileEntity fileEntity = testEntity.getExtraFilesId() == null ? + null : fileRepository.findById(testEntity.getExtraFilesId()).orElse(null); + if (fileEntity == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("No extra files found"); + } + + testEntity.setExtraFilesId(null); + testEntity = testRepository.save(testEntity); + + CheckResult delResult = fileUtil.deleteFileById(fileEntity.getId()); + if (!delResult.getStatus().equals(HttpStatus.OK)) { + return ResponseEntity.status(delResult.getStatus()).body(delResult.getMessage()); + } + + return ResponseEntity.ok(entityToJsonConverter.testEntityToTestJson(testEntity, projectId)); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error while deleting files"); + } + } + + /** + * Function to get extra files for a test + * @param projectId the id of the project to get the files for + * @param auth the authentication object of the requesting user + * @HttpMethod GET + * @ApiDog apiDog documentation + * @AllowedRoles teacher, student + * @ApiPath /api/projects/{projectid}/tests/extrafiles + * @return ResponseEntity with the updated tests + */ + @GetMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/tests/extrafiles") + @Roles({UserRole.teacher, UserRole.student}) + public ResponseEntity getExtraTestFiles( + @PathVariable("projectid") long projectId, + Auth auth + ) { + CheckResult checkResult = testUtil.getTestIfAdmin(projectId, auth.getUserEntity()); + if (!checkResult.getStatus().equals(HttpStatus.OK)) { + return ResponseEntity.status(checkResult.getStatus()).body(checkResult.getMessage()); + } + + TestEntity testEntity = checkResult.getData(); + if (testEntity.getExtraFilesId() == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("No extra files found"); + } + + FileEntity fileEntity = fileRepository.findById(testEntity.getExtraFilesId()).orElse(null); + if (fileEntity == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("No extra files found"); + } + + return Filehandler.getZipFileAsResponse(Path.of(fileEntity.getPath()), fileEntity.getName()); + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/UserController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/UserController.java index 7351275b..566c2407 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/UserController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/UserController.java @@ -56,6 +56,18 @@ public ResponseEntity getUserById(@PathVariable("userid") Long userid,Au return ResponseEntity.ok().body(res); } + /** + * Function to search users by email, name and surname + * + * @param email email of a user + * @param name name of a user + * @param surname surname of a user + * @HttpMethod GET + * @ApiPath /api/user + * @AllowedRoles admin + * @ApiDog apiDog documentation + * @return user object + */ @GetMapping(ApiRoutes.USERS_BASE_PATH) @Roles({UserRole.admin}) public ResponseEntity getUsersByNameOrSurname( @@ -91,7 +103,16 @@ public ResponseEntity getUsersByNameOrSurname( return ResponseEntity.ok().body(usersByName.stream().map(UserJson::new).toList()); } - + /** + * Function to get the logged in user + * + * @param auth authentication object + * @HttpMethod GET + * @ApiPath /api/user + * @AllowedRoles student + * @ApiDog apiDog documentation + * @return user object + */ @GetMapping(ApiRoutes.LOGGEDIN_USER_PATH) @Roles({UserRole.student, UserRole.teacher}) public ResponseEntity getLoggedInUser(Auth auth) { diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/Auth.java b/backend/app/src/main/java/com/ugent/pidgeon/model/Auth.java index 4add2b68..2ebd970e 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/Auth.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/Auth.java @@ -29,6 +29,7 @@ public String getName(){ public String getEmail(){ return user.email; } + public String getStudentNumber() { return user.studentnumber; } public String getOid(){ return user.oid; diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/ProjectResponseJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/ProjectResponseJson.java index 88e5e0fe..1b4b0ca3 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/ProjectResponseJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/ProjectResponseJson.java @@ -21,5 +21,6 @@ public record ProjectResponseJson( boolean visible, ProjectProgressJson progress, Long groupId, - Long clusterId + Long clusterId, + OffsetDateTime visibleAfter ) {} diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/User.java b/backend/app/src/main/java/com/ugent/pidgeon/model/User.java index 330c74e7..0ce04625 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/User.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/User.java @@ -9,12 +9,14 @@ public class User { public String lastName; public String email; public String oid; + public String studentnumber; - public User (String name, String firstName, String lastName, String email, String oid) { + public User (String name, String firstName, String lastName, String email, String oid, String studentnumber) { this.name = name; this.email = email; this.oid = oid; this.firstName = firstName; this.lastName = lastName; + this.studentnumber = studentnumber; } } \ No newline at end of file diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterCreateJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterCreateJson.java index 8e461631..e7e7161a 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterCreateJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterCreateJson.java @@ -1,9 +1,12 @@ package com.ugent.pidgeon.model.json; +import java.time.OffsetDateTime; + public record GroupClusterCreateJson( String name, Integer capacity, - Integer groupCount + Integer groupCount, + OffsetDateTime lockGroupsAfter ) { public GroupClusterCreateJson { } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterJson.java index 30714044..6d29accd 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterJson.java @@ -10,6 +10,7 @@ public record GroupClusterJson( int groupCount, OffsetDateTime createdAt, List groups, + OffsetDateTime lockGroupsAfter, String courseUrl ) { diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterUpdateJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterUpdateJson.java index 3d8b3a2a..72ea9ec0 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterUpdateJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/GroupClusterUpdateJson.java @@ -1,8 +1,11 @@ package com.ugent.pidgeon.model.json; +import java.time.OffsetDateTime; + public class GroupClusterUpdateJson { private String name; private Integer capacity; + private OffsetDateTime lockGroupsAfter; public GroupClusterUpdateJson() { } @@ -16,6 +19,10 @@ public Integer getCapacity() { return capacity; } + public OffsetDateTime getLockGroupsAfter() { + return lockGroupsAfter; + } + // Setters public void setName(String name) { this.name = name; @@ -24,4 +31,8 @@ public void setName(String name) { public void setCapacity(Integer capacity) { this.capacity = capacity; } + + public void setLockGroupsAfter(OffsetDateTime lockGroupsAfter) { + this.lockGroupsAfter = lockGroupsAfter; + } } \ No newline at end of file diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/ProjectJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/ProjectJson.java index abbf1b22..11c49711 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/ProjectJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/ProjectJson.java @@ -15,6 +15,7 @@ public class ProjectJson { private Long groupClusterId; private Boolean visible; private Integer maxScore; + private OffsetDateTime visibleAfter; @JsonSerialize(using = OffsetDateTimeSerializer.class) private OffsetDateTime deadline; @@ -79,4 +80,12 @@ public Integer getMaxScore() { public void setMaxScore(Integer maxScore) { this.maxScore = maxScore; } + + public OffsetDateTime getVisibleAfter() { + return visibleAfter; + } + + public void setVisibleAfter(OffsetDateTime visibleAfter) { + this.visibleAfter = visibleAfter; + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/TestJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/TestJson.java index e2c0d034..76c9bbb2 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/TestJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/TestJson.java @@ -1,22 +1,28 @@ package com.ugent.pidgeon.model.json; public class TestJson { + private String projectUrl; private String dockerImage; private String dockerScript; private String dockerTemplate; private String structureTest; + private String extraFilesUrl; + private String extraFilesName; + public TestJson() { } public TestJson(String projectUrl, String dockerImage, String dockerScript, - String dockerTemplate, String structureTest) { + String dockerTemplate, String structureTest, String extraFilesUrl, String extraFilesName) { this.projectUrl = projectUrl; this.dockerImage = dockerImage; this.dockerScript = dockerScript; - this.dockerTemplate = dockerTemplate; - this.structureTest = structureTest; + this.dockerTemplate = dockerTemplate; + this.structureTest = structureTest; + this.extraFilesUrl = extraFilesUrl; + this.extraFilesName = extraFilesName; } public String getProjectUrl() { @@ -58,4 +64,20 @@ public String getDockerTemplate() { public void setDockerTemplate(String dockerTemplate) { this.dockerTemplate = dockerTemplate; } + + public String getExtraFilesUrl() { + return extraFilesUrl; + } + + public void setExtraFilesUrl(String extraFilesUrl) { + this.extraFilesUrl = extraFilesUrl; + } + + public String getExtraFilesName() { + return extraFilesName; + } + + public void setExtraFilesName(String extraFilesName) { + this.extraFilesName = extraFilesName; + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/UserJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/UserJson.java index bcba3900..3f8940e3 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/UserJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/UserJson.java @@ -14,6 +14,7 @@ public class UserJson { private String surname; private String email; private UserRole role; + private String studentNumber; private OffsetDateTime createdAt; @@ -29,6 +30,7 @@ public UserJson(UserEntity entity) { this.email = entity.getEmail(); this.role = entity.getRole(); this.createdAt = entity.getCreatedAt(); + this.studentNumber = entity.getStudentNumber(); // this.courses = new ArrayList<>(); } @@ -96,4 +98,11 @@ public String getProjectUrl() { } public void setProjectUrl(String s){} + public String getStudentNumber() { + return studentNumber; + } + + public void setStudentNumber(String studentNumber) { + this.studentNumber = studentNumber; + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/json/UserReferenceJson.java b/backend/app/src/main/java/com/ugent/pidgeon/model/json/UserReferenceJson.java index b14d4068..1d486fae 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/json/UserReferenceJson.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/json/UserReferenceJson.java @@ -4,11 +4,13 @@ public class UserReferenceJson { private String name; private String email; private Long userId; + private String studentNumber; - public UserReferenceJson(String name, String email, Long userId) { + public UserReferenceJson(String name, String email, Long userId, String studentNumber) { this.name = name; this.email = email; this.userId = userId; + this.studentNumber = studentNumber; } public String getEmail() { @@ -39,4 +41,11 @@ public void setName(String name) { } + public String getStudentNumber() { + return studentNumber; + } + + public void setStudentNumber(String studentNumber) { + this.studentNumber = studentNumber; + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubmissionTestModel.java b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubmissionTestModel.java index 7e67c969..fdddad2c 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubmissionTestModel.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/DockerSubmissionTestModel.java @@ -14,6 +14,9 @@ import java.io.File; import java.io.FileReader; import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Enumeration; @@ -56,6 +59,7 @@ public DockerSubmissionTestModel(String dockerImage) { new File(localMountFolder + "input/").mkdirs(); new File(localMountFolder + "output/").mkdirs(); new File(localMountFolder + "artifacts/").mkdirs(); + new File(localMountFolder + "extra/").mkdirs(); } @@ -86,6 +90,33 @@ public void addInputFiles(File[] files) { } } + public void addUtilFiles(Path pathToZip){ + // first unzip files to the utils folder + try { + ZipFile zipFile = new ZipFile(pathToZip.toFile()); + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + File entryDestination = new File(localMountFolder + "extra/", entry.getName()); + if (entry.isDirectory()) { + entryDestination.mkdirs(); + } else { + File parent = entryDestination.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + try { + FileUtils.copyInputStreamToFile(zipFile.getInputStream(entry), entryDestination); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } catch (IOException e) { + e.printStackTrace(); + } + } + public void addZipInputFiles(ZipFile zipFile) { Enumeration entries = zipFile.entries(); while (entries.hasMoreElements()) { @@ -283,23 +314,34 @@ public static void removeDockerImage(String imageName) { } public static boolean imageExists(String image) { - DockerClient dockerClient = DockerClientInstance.getInstance(); try { - dockerClient.inspectImageCmd(image).exec(); - } catch (Exception e) { + // Split the image into repository and tag + String[] parts = image.split(":"); + String repository = parts[0]; + String tag = parts.length > 1 ? parts[1] : "latest"; + + // Construct the URL for the Docker Hub API + String apiUrl = "https://hub.docker.com/v2/repositories/library/" + repository + "/tags/" + tag; + URL url = new URL(apiUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.connect(); + int responseCode = connection.getResponseCode(); + + return (responseCode == 200); + } catch (IOException e) { return false; } - return true; } - public static boolean isValidTemplate(String template) { + public static void tryTemplate(String template) { // lines with @ should be the first of a string // @ is always the first character // ">" options under the template should be "required, optional or description="..." boolean atLeastOne = false; // Template should not be empty String[] lines = template.split("\n"); if (lines[0].charAt(0) != '@') { - return false; + throw new IllegalArgumentException("Template should start with a '@'"); } boolean isConfigurationLine = false; for (String line : lines) { @@ -317,14 +359,25 @@ public static boolean isValidTemplate(String template) { // option lines if (!line.equalsIgnoreCase(">Required") && !line.equalsIgnoreCase(">Optional") && !isDescription) { - return false; + throw new IllegalArgumentException("Invalid option in template"); } } else { isConfigurationLine = false; } } } - return atLeastOne; + if(! atLeastOne){ + throw new IllegalArgumentException("Template should not be empty"); + } + } + + public static boolean isValidTemplate(String template){ + try{ + tryTemplate(template); + return true; + }catch (Exception e){ + return false; + } } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/SubmissionTemplateModel.java b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/SubmissionTemplateModel.java index 13bdd40b..1e83f944 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/SubmissionTemplateModel.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/model/submissionTesting/SubmissionTemplateModel.java @@ -13,10 +13,39 @@ public class SubmissionTemplateModel { private static class FileEntry { public String name; Pattern pattern; + private String invert(String name){ + // invert . with \. for simpler filenames + List dotLocations = new ArrayList<>(); + List escapedDotLocations = new ArrayList<>(); + for (int i = 0; i < name.length(); i++) { + if(i > 0 && name.charAt(i - 1) == '\\' && name.charAt(i) == '.'){ + escapedDotLocations.add(i); + }else if (name.charAt(i) == '.') { + dotLocations.add(i); + } + } + StringBuilder sb = new StringBuilder(); + for(int i = 0; i < name.length(); i++) { + if(escapedDotLocations.contains(i + 1)){ + // skip the break + continue; + }else if(escapedDotLocations.contains(i)){ + sb.append("."); + }else if(dotLocations.contains(i)){ + sb.append("\\."); + }else{ + sb.append(name.charAt(i)); + } + } + return sb.toString(); + + } public FileEntry(String name) { - this.name = name; - pattern = Pattern.compile("^" + name + "$"); // hat for defining start of the string, $ defines the end + + this.name = invert(name); + + pattern = Pattern.compile("^" + this.name + "$"); // hat for defining start of the string, $ defines the end } public boolean matches(String fileName) { @@ -52,7 +81,6 @@ public void parseSubmissionTemplate(String templateString) { mostSpaces = spaceAmount; } lines[i] = "\t".repeat(tabsPerSpaces.get(spaceAmount)) + line.replaceAll(" ", ""); - ; } // Create folder stack for keeping track of all the folders while exploring the insides @@ -175,4 +203,66 @@ public SubmissionResult checkSubmission(String file) throws IOException { return checkSubmission(new ZipFile(file)); } + // will throw error if there are errors in the template + public static void tryTemplate(String template) throws IllegalArgumentException { + List lines = List.of(template.split("\n")); + // check if the template is valid, control if every line contains a file parsable string + // check if the file is in a valid folder location (indentation is correct) + // check if the first file has indentation 0 + List indentionAmounts = new ArrayList<>(); + indentionAmounts.add(0); + if(getIndentation(lines.get(0)) != 0){ + throw new IllegalArgumentException("First file should not have any spaces or tabs."); + } + boolean newFolder = false; + for(int line_index = 0; line_index < lines.size(); line_index++){ + String line = lines.get(line_index); + int indentation = getIndentation(line); + if(line.isEmpty()){ + throw new IllegalArgumentException("Empty file name in template, remove blank lines"); + } + if(newFolder && indentation > indentionAmounts.get(indentionAmounts.size() - 1)){ + // since the indentation is larger than the previous, we are dealing with the first file in a new folder + indentionAmounts.add(indentation); + newFolder = false; + }else{ + // we are dealing with a file in a folder, thus the indentation should be equal to one of the previous folders + for(int i = indentionAmounts.size() - 1; i >= 0; i--){ + if(indentionAmounts.get(i) == indentation){ + break; + } + if(i == 0){ + throw new IllegalArgumentException("File at line "+ line_index + " is not in a valid folder location (indentation is incorrect)"); + } + } + // check if file is correct, since location is correct + + // first check if file contains valid file names + if(line.substring(0,line.length() - 1).contains("/")){ + throw new IllegalArgumentException("File/folder at line "+ (line_index+1) + " contains invalid characters"); + } + // check if file is a folder + if(line.charAt(line.length() - 1) == '/') { + newFolder = true; + } + } + if(line.charAt(line.length() - 1) == '/'){ + // new folder start! + newFolder = true; + } + } + } + + + private static int getIndentation(String line){ + int length = line.length(); + // one space is equal to a tab + for(int i = 0; i < length; i++){ + if(line.charAt(i) != ' ' && line.charAt(i) != '\t'){ + return i; + } + } + return length - 1; + } + } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/GroupClusterEntity.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/GroupClusterEntity.java index ea2818bb..cc9678ae 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/GroupClusterEntity.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/GroupClusterEntity.java @@ -26,6 +26,9 @@ public class GroupClusterEntity { @Column(name="group_amount", nullable=false) private int groupAmount; + @Column(name = "lock_groups_after") + private OffsetDateTime lockGroupsAfter; + @Column(name = "created_at") private OffsetDateTime createdAt; @@ -87,4 +90,12 @@ public OffsetDateTime getCreatedAt() { public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + public OffsetDateTime getLockGroupsAfter() { + return lockGroupsAfter; + } + + public void setLockGroupsAfter(OffsetDateTime lockGroupsAfter) { + this.lockGroupsAfter = lockGroupsAfter; + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/ProjectEntity.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/ProjectEntity.java index 67dc778a..b3cc7bac 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/ProjectEntity.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/ProjectEntity.java @@ -38,6 +38,10 @@ public class ProjectEntity { @Column(name="max_score") private Integer maxScore; + @Column(name = "visible_after") + private OffsetDateTime visibleAfter; + + public ProjectEntity(long courseId, String name, String description, long groupClusterId, Long testId, Boolean visible, Integer maxScore, OffsetDateTime deadline) { this.courseId = courseId; this.name = name; @@ -124,4 +128,12 @@ public OffsetDateTime getDeadline() { public void setDeadline(OffsetDateTime deadline) { this.deadline = deadline; } + + public OffsetDateTime getVisibleAfter() { + return visibleAfter; + } + + public void setVisibleAfter(OffsetDateTime visibleAfter) { + this.visibleAfter = visibleAfter; + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/SubmissionEntity.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/SubmissionEntity.java index 2ecdcd8e..0b14e27e 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/SubmissionEntity.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/SubmissionEntity.java @@ -18,7 +18,7 @@ public class SubmissionEntity { private long projectId; @Column(name="group_id", nullable=false) - private long groupId; + private Long groupId; @Column(name="file_id", nullable=false) private long fileId; @@ -47,7 +47,7 @@ public class SubmissionEntity { public SubmissionEntity() { } - public SubmissionEntity(long projectId, long groupId, Long fileId, OffsetDateTime submissionTime, Boolean structureAccepted, Boolean dockerAccepted) { + public SubmissionEntity(long projectId, Long groupId, Long fileId, OffsetDateTime submissionTime, Boolean structureAccepted, Boolean dockerAccepted) { this.projectId = projectId; this.groupId = groupId; this.fileId = fileId; @@ -56,10 +56,14 @@ public SubmissionEntity(long projectId, long groupId, Long fileId, OffsetDateTim this.dockerAccepted = dockerAccepted; } - public long getGroupId() { + public Long getGroupId() { return groupId; } + public void setGroupId(Long groupId) { + this.groupId = groupId; + } + public long getFileId() { return fileId; } @@ -155,4 +159,6 @@ public DockerTestType getDockerTestType() { public void setDockerType(DockerTestType dockerType) { this.dockerType = dockerType.toString(); } + + } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/TestEntity.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/TestEntity.java index 885c6a49..efe8afe4 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/TestEntity.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/TestEntity.java @@ -24,6 +24,9 @@ public class TestEntity { @Column(name = "structure_template") private String structureTemplate; + @Column(name = "extra_files") + private Long extraFilesId; + public TestEntity(String dockerImage, String docker_test_script, String dockerTestTemplate, String structureTemplate) { @@ -76,4 +79,12 @@ public String getStructureTemplate() { public void setStructureTemplate(String structureTemplate) { this.structureTemplate = structureTemplate; } + + public Long getExtraFilesId() { + return extraFilesId; + } + + public void setExtraFilesId(Long extraFilesId) { + this.extraFilesId = extraFilesId; + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/UserEntity.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/UserEntity.java index d39ac2cc..0f3a04ca 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/UserEntity.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/models/UserEntity.java @@ -34,12 +34,17 @@ public class UserEntity { @Column(name = "created_at") private OffsetDateTime createdAt; - public UserEntity(String name, String surname, String email, UserRole role, String azureId) { + @Column(name = "studentnumber") + private String studentNumber; + + public UserEntity(String name, String surname, String email, UserRole role, String azureId, + String studentNumber) { this.name = name; this.surname = surname; this.email = email; this.role = role.toString(); this.azureId = azureId; + this.studentNumber = studentNumber; } public UserEntity() { @@ -110,5 +115,13 @@ public OffsetDateTime getCreatedAt() { public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + public String getStudentNumber() { + return studentNumber; + } + + public void setStudentNumber(String studentNumber) { + this.studentNumber = studentNumber; + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/GroupRepository.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/GroupRepository.java index 1a3e1d07..65af7bc0 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/GroupRepository.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/GroupRepository.java @@ -36,9 +36,10 @@ public interface UserReference { Long getUserId(); String getName(); String getEmail(); + String getStudentNumber(); } @Query(value= """ - SELECT gu.userId as userId, u.name, CONCAT(u.name, ' ', u.surname) as name, u.email as email + SELECT gu.userId as userId, u.name, CONCAT(u.name, ' ', u.surname) as name, u.email as email, u.studentNumber as studentNumber FROM GroupUserEntity gu JOIN UserEntity u ON u.id = gu.userId WHERE gu.groupId = ?1""") List findGroupUsersReferencesByGroupId(long id); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/SubmissionRepository.java b/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/SubmissionRepository.java index a2df7c9a..c000a12a 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/SubmissionRepository.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/postgre/repository/SubmissionRepository.java @@ -32,5 +32,12 @@ SELECT MAX(s2.submissionTime) """) Optional findLatestsSubmissionIdsByProjectAndGroupId(long projectId, long groupId); + @Query(value = """ + SELECT s FROM SubmissionEntity s + WHERE s.projectId = :projectId + AND s.groupId IS NULL + """) + List findAdminSubmissionsByProjectId(long projectId); + List findByProjectIdAndGroupId(long projectid, long groupid); } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/EntityToJsonConverter.java b/backend/app/src/main/java/com/ugent/pidgeon/util/EntityToJsonConverter.java index 716ae1bf..961f335f 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/EntityToJsonConverter.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/EntityToJsonConverter.java @@ -9,6 +9,8 @@ import com.ugent.pidgeon.postgre.models.types.DockerTestState; import com.ugent.pidgeon.postgre.models.types.DockerTestType; import com.ugent.pidgeon.postgre.repository.*; +import java.io.File; +import java.nio.file.Path; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -39,9 +41,11 @@ public class EntityToJsonConverter { private TestUtil testUtil; @Autowired private TestRepository testRepository; + @Autowired + private FileRepository fileRepository; - public GroupJson groupEntityToJson(GroupEntity groupEntity) { + public GroupJson groupEntityToJson(GroupEntity groupEntity, boolean hideStudentNumber) { GroupClusterEntity cluster = groupClusterRepository.findById(groupEntity.getClusterId()).orElse(null); if (cluster == null) { throw new RuntimeException("Cluster not found"); @@ -54,7 +58,7 @@ public GroupJson groupEntityToJson(GroupEntity groupEntity) { } // Get the members of the group List members = groupRepository.findGroupUsersReferencesByGroupId(groupEntity.getId()).stream().map(user -> - new UserReferenceJson(user.getName(), user.getEmail(), user.getUserId()) + new UserReferenceJson(user.getName(), user.getEmail(), user.getUserId(), hideStudentNumber ? null : user.getStudentNumber()) ).toList(); // Return the group with its members @@ -63,9 +67,9 @@ public GroupJson groupEntityToJson(GroupEntity groupEntity) { } - public GroupClusterJson clusterEntityToClusterJson(GroupClusterEntity cluster) { + public GroupClusterJson clusterEntityToClusterJson(GroupClusterEntity cluster, boolean hideStudentNumber) { List groups = groupRepository.findAllByClusterId(cluster.getId()).stream().map( - this::groupEntityToJson + g -> groupEntityToJson(g, hideStudentNumber) ).toList(); return new GroupClusterJson( cluster.getId(), @@ -74,24 +78,31 @@ public GroupClusterJson clusterEntityToClusterJson(GroupClusterEntity cluster) { cluster.getGroupAmount(), cluster.getCreatedAt(), groups, + cluster.getLockGroupsAfter(), ApiRoutes.COURSE_BASE_PATH + "/" + cluster.getCourseId() ); } - public UserReferenceJson userEntityToUserReference(UserEntity user) { - return new UserReferenceJson(user.getName() + " " + user.getSurname(), user.getEmail(), user.getId()); + public UserReferenceJson userEntityToUserReference(UserEntity user, boolean hideStudentNumber) { + return new UserReferenceJson( + user.getName() + " " + user.getSurname(), + user.getEmail(), user.getId(), + hideStudentNumber ? null : user.getStudentNumber() + ); } - public UserReferenceWithRelation userEntityToUserReferenceWithRelation(UserEntity user, CourseRelation relation) { - return new UserReferenceWithRelation(userEntityToUserReference(user), relation.toString()); + public UserReferenceWithRelation userEntityToUserReferenceWithRelation(UserEntity user, CourseRelation relation, boolean hideStudentNumber) { + return new UserReferenceWithRelation(userEntityToUserReference(user, hideStudentNumber), relation.toString()); } public CourseWithInfoJson courseEntityToCourseWithInfo(CourseEntity course, String joinLink, boolean hideKey) { UserEntity teacher = courseRepository.findTeacherByCourseId(course.getId()); - UserReferenceJson teacherJson = userEntityToUserReference(teacher); + UserReferenceJson teacherJson = userEntityToUserReference(teacher, true); List assistants = courseRepository.findAssistantsByCourseId(course.getId()); - List assistantsJson = assistants.stream().map(this::userEntityToUserReference).toList(); + List assistantsJson = assistants.stream().map( + u -> userEntityToUserReference(u, true) + ).toList(); return new CourseWithInfoJson( course.getId(), @@ -215,7 +226,8 @@ public ProjectResponseJson projectEntityToProjectResponseJson(ProjectEntity proj project.isVisible(), new ProjectProgressJson(completed, total), groupId, - clusterId + clusterId, + project.getVisibleAfter() ); } @@ -242,10 +254,18 @@ else if (submission.getDockerTestType().equals(DockerTestType.SIMPLE)) { } else { feedback = new DockerTestFeedbackJson(DockerTestType.TEMPLATE, submission.getDockerFeedback(), submission.getDockerAccepted()); } + + boolean artifactsExist; + if (submission.getGroupId() != null) { + Path artifactPath = Filehandler.getSubmissionArtifactPath(submission.getProjectId(), submission.getGroupId(), submission.getId()); + artifactsExist = new File(artifactPath.toString()).exists(); + } else { + artifactsExist = false; + } return new SubmissionJson( submission.getId(), ApiRoutes.PROJECT_BASE_PATH + "/" + submission.getProjectId(), - ApiRoutes.GROUP_BASE_PATH + "/" + submission.getGroupId(), + submission.getGroupId() == null ? null : ApiRoutes.GROUP_BASE_PATH + "/" + submission.getGroupId(), submission.getProjectId(), submission.getGroupId(), ApiRoutes.SUBMISSION_BASE_PATH + "/" + submission.getId() + "/file", @@ -254,17 +274,20 @@ else if (submission.getDockerTestType().equals(DockerTestType.SIMPLE)) { submission.getStructureFeedback(), feedback, submission.getDockerTestState().toString(), - ApiRoutes.SUBMISSION_BASE_PATH + "/" + submission.getId() + "/artifacts" + artifactsExist ? ApiRoutes.SUBMISSION_BASE_PATH + "/" + submission.getId() + "/artifacts" : null ); } public TestJson testEntityToTestJson(TestEntity testEntity, long projectId) { + FileEntity extrafiles = testEntity.getExtraFilesId() == null ? null : fileRepository.findById(testEntity.getExtraFilesId()).orElse(null); return new TestJson( ApiRoutes.PROJECT_BASE_PATH + "/" + projectId, testEntity.getDockerImage(), testEntity.getDockerTestScript(), testEntity.getDockerTestTemplate(), - testEntity.getStructureTemplate() + testEntity.getStructureTemplate(), + testEntity.getExtraFilesId() == null ? null : ApiRoutes.PROJECT_BASE_PATH + "/" + projectId + "/tests/extrafiles", + extrafiles == null ? null : extrafiles.getName() ); } } \ No newline at end of file diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/Filehandler.java b/backend/app/src/main/java/com/ugent/pidgeon/util/Filehandler.java index 071d1d4f..94e6a031 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/Filehandler.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/Filehandler.java @@ -5,6 +5,9 @@ import java.util.zip.ZipOutputStream; import org.apache.tika.Tika; import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.multipart.MultipartFile; import org.springframework.core.io.Resource; @@ -18,6 +21,8 @@ public class Filehandler { static String BASEPATH = "data"; public static String SUBMISSION_FILENAME = "files.zip"; + public static String EXTRA_TESTFILES_FILENAME = "testfiles.zip"; + public static String ADMIN_SUBMISSION_FOLDER = "adminsubmissions"; /** * Save a submission to the server @@ -26,7 +31,7 @@ public class Filehandler { * @return the saved file * @throws IOException if an error occurs while saving the file */ - public static File saveSubmission(Path directory, MultipartFile file) throws IOException { + public static File saveFile(Path directory, MultipartFile file, String filename) throws IOException { // Check if the file is empty if (file == null || file.isEmpty()) { throw new IOException("File is empty"); @@ -34,7 +39,7 @@ public static File saveSubmission(Path directory, MultipartFile file) throws IOE try { // Create a temporary file and save the uploaded file to it - File tempFile = File.createTempFile("uploaded-zip-", ".zip"); + File tempFile = File.createTempFile("SELAB6CANDELETEuploaded-zip-", ".zip"); file.transferTo(tempFile); // Check if the file is a ZIP file @@ -50,7 +55,7 @@ public static File saveSubmission(Path directory, MultipartFile file) throws IOE } // Save the file to the server - Path filePath = directory.resolve(SUBMISSION_FILENAME); + Path filePath = directory.resolve(filename); try(InputStream stream = new FileInputStream(tempFile)) { Files.copy(stream, filePath, StandardCopyOption.REPLACE_EXISTING); @@ -109,14 +114,21 @@ private static void deleteEmptyParentDirectories(File directory) throws IOExcept * @param submissionid id of the submission * @return the path of the submission */ - static public Path getSubmissionPath(long projectid, long groupid, long submissionid) { + static public Path getSubmissionPath(long projectid, Long groupid, long submissionid) { + if (groupid == null) { + return Path.of(BASEPATH,"projects", String.valueOf(projectid), ADMIN_SUBMISSION_FOLDER, String.valueOf(submissionid)); + } return Path.of(BASEPATH,"projects", String.valueOf(projectid), String.valueOf(groupid), String.valueOf(submissionid)); } - static public Path getSubmissionArtifactPath(long projectid, long groupid, long submissionid) { + static public Path getSubmissionArtifactPath(long projectid, Long groupid, long submissionid) { return getSubmissionPath(projectid, groupid, submissionid).resolve("artifacts.zip"); } + static public Path getTestExtraFilesPath(long projectid) { + return Path.of(BASEPATH,"projects", String.valueOf(projectid)); + } + /** * Get a file as a resource * @param path path of the file @@ -184,4 +196,32 @@ public static void copyFilesAsZip(List files, Path path) throws IOExceptio } } } + + public static ResponseEntity getZipFileAsResponse(Path path, String filename) { + // Get the file from the server + Resource zipFile = Filehandler.getFileAsResource(path); + if (zipFile == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("File not found."); + } + + // Set headers for the response + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename); + headers.add(HttpHeaders.CONTENT_TYPE, "application/zip"); + + return ResponseEntity.ok() + .headers(headers) + .body(zipFile); + } + + + public static void addExistingZip(ZipOutputStream groupZipOut, String zipFileName, File zipFile) throws IOException { + ZipEntry zipEntry = new ZipEntry(zipFileName); + groupZipOut.putNextEntry(zipEntry); + + // Read the content of the zip file and write it to the group zip output stream + Files.copy(zipFile.toPath(), groupZipOut); + + groupZipOut.closeEntry(); + } } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/GroupFeedbackUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/GroupFeedbackUtil.java index 03e21232..b1dc7e52 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/GroupFeedbackUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/GroupFeedbackUtil.java @@ -100,15 +100,15 @@ public CheckResult checkGroupFeedbackUpdateJson(UpdateGroupScoreRequest re return new CheckResult<>(projectCheck.getStatus(), projectCheck.getMessage(), null); } Integer maxScore = projectCheck.getData().getMaxScore(); - if ((request.getScore() == null && maxScore != null) || request.getFeedback() == null) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "Score and feedback need to be provided", null); + if (request.getFeedback() == null) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "Feedbacks need to be provided", null); } if (request.getScore() != null && request.getScore() < 0) { return new CheckResult<>(HttpStatus.BAD_REQUEST, "Score can't be lower than 0", null); } - if (maxScore != null && request.getScore() > maxScore) { + if (maxScore != null && request.getScore() != null && request.getScore() > maxScore) { return new CheckResult<>(HttpStatus.BAD_REQUEST, "Score can't be higher than the defined max score (" + maxScore + ")", null); } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/GroupUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/GroupUtil.java index abb96d06..00cd59c2 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/GroupUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/GroupUtil.java @@ -7,6 +7,8 @@ import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.GroupClusterRepository; import com.ugent.pidgeon.postgre.repository.GroupRepository; +import java.time.OffsetDateTime; +import javax.swing.GroupLayout.Group; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; @@ -142,7 +144,12 @@ public CheckResult canAddUserToGroup(long groupId, long userId, UserEntity return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot add user to individual group", null); } - if (isAdminOfGroup(groupId, userToAdd).getStatus() == HttpStatus.OK) { + OffsetDateTime lockGroupTime = cluster.getData().getLockGroupsAfter(); + if (lockGroupTime != null && lockGroupTime.isBefore(OffsetDateTime.now()) && !isAdmin) { + return new CheckResult<>(HttpStatus.FORBIDDEN, "Groups are locked", null); + } + + if (isAdminOfGroup(groupId, userToAdd).getStatus().equals(HttpStatus.OK)) { return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot add a course admin to a group", null); } @@ -166,10 +173,19 @@ public CheckResult canRemoveUserFromGroup(long groupId, long userId, UserE if (admin.getStatus() != HttpStatus.OK) { return admin; } + } else { if (groupClusterRepository.inArchivedCourse(group.getClusterId())) { return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot leave a group in an archived course", null); } + CheckResult cluster = clusterUtil.getClusterIfExists(group.getClusterId()); + if (cluster.getStatus() != HttpStatus.OK) { + return new CheckResult<>(HttpStatus.INTERNAL_SERVER_ERROR, "Error while checking cluster", null); + } + OffsetDateTime lockGroupTime = cluster.getData().getLockGroupsAfter(); + if (lockGroupTime != null && lockGroupTime.isBefore(OffsetDateTime.now())) { + return new CheckResult<>(HttpStatus.FORBIDDEN, "Groups are locked", null); + } } if (!groupRepository.userInGroup(groupId, userId)) { return new CheckResult<>(HttpStatus.NOT_FOUND, "User is not in the group", null); @@ -189,16 +205,16 @@ public CheckResult canRemoveUserFromGroup(long groupId, long userId, UserE * @param user user that wants to get the submissions * @return CheckResult with the status of the check */ - public CheckResult canGetProjectGroupData(long groupId, long projectId, UserEntity user) { + public CheckResult canGetProjectGroupData(Long groupId, long projectId, UserEntity user) { CheckResult projectCheck = projectUtil.getProjectIfExists(projectId); if (projectCheck.getStatus() != HttpStatus.OK) { return new CheckResult<>(projectCheck.getStatus(), projectCheck.getMessage(), null); } ProjectEntity project = projectCheck.getData(); - if (groupRepository.findByIdAndClusterId(groupId, project.getGroupClusterId()).isEmpty()) { + if (groupId != null && groupRepository.findByIdAndClusterId(groupId, project.getGroupClusterId()).isEmpty()) { return new CheckResult<>(HttpStatus.NOT_FOUND, "Group not part of the project", null); } - boolean inGroup = groupRepository.userInGroup(groupId, user.getId()); + boolean inGroup = groupId != null && groupRepository.userInGroup(groupId, user.getId()); boolean isAdmin = user.getRole().equals(UserRole.admin) || projectUtil.isProjectAdmin(projectId, user).getStatus().equals(HttpStatus.OK); if (inGroup || isAdmin) { return new CheckResult<>(HttpStatus.OK, "", null); diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java index d3135dac..866eed19 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/SubmissionUtil.java @@ -75,32 +75,34 @@ public CheckResult canDeleteSubmission(long submissionId, User * @return CheckResult with the status of the check and the group id */ public CheckResult checkOnSubmit(long projectId, UserEntity user) { + CheckResult projectCheck = projectUtil.getProjectIfExists(projectId); + if (projectCheck.getStatus() != HttpStatus.OK) { + return new CheckResult<> (projectCheck.getStatus(), projectCheck.getMessage(), null); + } + + ProjectEntity project = projectCheck.getData(); + if (!projectUtil.userPartOfProject(projectId, user.getId())) { return new CheckResult<>(HttpStatus.FORBIDDEN, "You aren't part of this project", null); } Long groupId = groupRepository.groupIdByProjectAndUser(projectId, user.getId()); if (groupId == null) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "User is not part of a group for this project", null); - } - - CheckResult groupCheck = groupUtil.getGroupIfExists(groupId); - if (groupCheck.getStatus() != HttpStatus.OK) { - return new CheckResult<>(groupCheck.getStatus(), groupCheck.getMessage(), null); - } - GroupEntity group = groupCheck.getData(); + CheckResult projectAdminCheck = projectUtil.isProjectAdmin(projectId, user); + if (projectAdminCheck.getStatus() != HttpStatus.OK) { + return new CheckResult<>(HttpStatus.BAD_REQUEST, "User is not part of a group for this project", null); + } + } else { + CheckResult groupCheck = groupUtil.getGroupIfExists(groupId); + if (groupCheck.getStatus() != HttpStatus.OK) { + return new CheckResult<>(groupCheck.getStatus(), groupCheck.getMessage(), null); + } - if (groupClusterRepository.inArchivedCourse(group.getClusterId())) { - return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot submit for a project in an archived course", null); + if (groupClusterRepository.inArchivedCourse(project.getGroupClusterId())) { + return new CheckResult<>(HttpStatus.FORBIDDEN, "Cannot submit for a project in an archived course", null); + } } - - CheckResult projectCheck = projectUtil.getProjectIfExists(projectId); - if (projectCheck.getStatus() != HttpStatus.OK) { - return new CheckResult<> (projectCheck.getStatus(), projectCheck.getMessage(), null); - } - - ProjectEntity project = projectCheck.getData(); OffsetDateTime time = OffsetDateTime.now(); Logger.getGlobal().info("Time: " + time + " Deadline: " + project.getDeadline()); if (time.isAfter(project.getDeadline())) { diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/TestRunner.java b/backend/app/src/main/java/com/ugent/pidgeon/util/TestRunner.java index 0b3cdc8d..25ca7c64 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/TestRunner.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/TestRunner.java @@ -27,7 +27,7 @@ public SubmissionTemplateModel.SubmissionResult runStructureTest( return model.checkSubmission(file); } - public DockerOutput runDockerTest(ZipFile file, TestEntity testEntity, Path outputPath, DockerSubmissionTestModel model) throws IOException { + public DockerOutput runDockerTest(ZipFile file, TestEntity testEntity, Path outputPath, DockerSubmissionTestModel model, long projectId) throws IOException { // Get the test file from the server String testScript = testEntity.getDockerTestScript(); String testTemplate = testEntity.getDockerTestTemplate(); @@ -41,6 +41,7 @@ public DockerOutput runDockerTest(ZipFile file, TestEntity testEntity, Path outp try { model.addZipInputFiles(file); + model.addUtilFiles(Filehandler.getTestExtraFilesPath(projectId).resolve(Filehandler.EXTRA_TESTFILES_FILENAME)); DockerOutput output; if (testTemplate == null) { diff --git a/backend/app/src/main/java/com/ugent/pidgeon/util/TestUtil.java b/backend/app/src/main/java/com/ugent/pidgeon/util/TestUtil.java index b3b57abb..663ac04e 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/util/TestUtil.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/util/TestUtil.java @@ -3,6 +3,7 @@ import com.ugent.pidgeon.controllers.ApiRoutes; import com.ugent.pidgeon.model.json.TestJson; import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; +import com.ugent.pidgeon.model.submissionTesting.SubmissionTemplateModel; import com.ugent.pidgeon.postgre.models.ProjectEntity; import com.ugent.pidgeon.postgre.models.TestEntity; import com.ugent.pidgeon.postgre.models.UserEntity; @@ -50,6 +51,7 @@ public CheckResult> checkForTestUpdate( String dockerImage, String dockerScript, String dockerTemplate, + String structureTemplate, HttpMethod httpMethod ) { @@ -95,10 +97,17 @@ public CheckResult> checkForTestUpdate( return new CheckResult<>(HttpStatus.BAD_REQUEST, "No docker test script is configured for this test", null); } - if(dockerTemplate != null && !DockerSubmissionTestModel.isValidTemplate(dockerTemplate)) { - return new CheckResult<>(HttpStatus.BAD_REQUEST, "Invalid docker template", null); + try { + // throws error if there are issues in the template + if(dockerTemplate != null) DockerSubmissionTestModel.tryTemplate(dockerTemplate); + if(structureTemplate != null) SubmissionTemplateModel.tryTemplate(structureTemplate); + + } catch(IllegalArgumentException e){ + return new CheckResult<>(HttpStatus.BAD_REQUEST, e.getMessage(), null); } + + return new CheckResult<>(HttpStatus.OK, "", new Pair<>(testEntity, projectEntity)); } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ClusterControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ClusterControllerTest.java index 2bd77265..440d329e 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ClusterControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ClusterControllerTest.java @@ -14,6 +14,7 @@ import com.ugent.pidgeon.util.*; import java.time.OffsetDateTime; import java.util.Collections; +import java.util.Objects; import java.util.logging.Logger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -31,6 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.argThat; @@ -91,6 +93,7 @@ public void setup() { groupClusterEntity.getGroupAmount(), OffsetDateTime.now(), Collections.emptyList(), + null, ""); groupEntity = new GroupEntity("groupName", 1L); groupEntity.setId(78L); @@ -105,12 +108,26 @@ public void testGetClustersForCourse() throws Exception { when(courseUtil.getCourseIfUserInCourse(courseId, getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity, CourseRelation.enrolled))); when(groupClusterRepository.findClustersWithoutInvidualByCourseId(courseId)).thenReturn(List.of(groupClusterEntity)); - when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity)).thenReturn(groupClusterJson); + when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity, true)).thenReturn(groupClusterJson); mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(List.of(groupClusterJson)))); + verify(entityToJsonConverter, times(1)).clusterEntityToClusterJson(groupClusterEntity, true); + + + /* If user is course_admin, studentnumber isn't hidden */ + when(courseUtil.getCourseIfUserInCourse(courseId, getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity, CourseRelation.course_admin))); + when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity, false)).thenReturn(groupClusterJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(groupClusterJson)))); + + verify(entityToJsonConverter, times(1)).clusterEntityToClusterJson(groupClusterEntity, false); + /* If a certain check fails, the corresponding status code is returned */ when(courseUtil.getCourseIfUserInCourse(anyLong(), any())) .thenReturn(new CheckResult<>(HttpStatus.BAD_REQUEST, "", null)); @@ -123,13 +140,13 @@ public void testCreateClusterForCourse() throws Exception { String url = ApiRoutes.COURSE_BASE_PATH + "/" + courseId +"/clusters"; /* If the user is an admin of the course and the json is valid, the cluster is created */ - String request = "{\"name\": \"test\", \"capacity\": 20, \"groupCount\": 5}"; + String request = "{\"name\": \"test\", \"capacity\": 20, \"groupCount\": 5, \"lockGroupsAfter\": null}"; when(courseUtil.getCourseIfAdmin(courseId, getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", courseEntity)); when(clusterUtil.checkGroupClusterCreateJson(argThat( - json -> json.name().equals("test") && json.capacity().equals(20) && json.groupCount().equals(5) + json -> json.name().equals("test") && json.capacity().equals(20) && json.groupCount().equals(5) && json.lockGroupsAfter() == null ))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); when(groupClusterRepository.save(any())).thenReturn(groupClusterEntity); - when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity)).thenReturn(groupClusterJson); + when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity, false)).thenReturn(groupClusterJson); mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) @@ -137,6 +154,18 @@ public void testCreateClusterForCourse() throws Exception { .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(groupClusterJson))); + /* lockGroupsAfter not null */ + request = "{\"name\": \"test\", \"capacity\": 20, \"groupCount\": 5, \"lockGroupsAfter\": \"2024-01-01T00:00:00Z\"}"; + reset(clusterUtil); + when(clusterUtil.checkGroupClusterCreateJson(argThat( + json -> json.name().equals("test") && json.capacity().equals(20) && json.groupCount().equals(5) && json.lockGroupsAfter().equals(OffsetDateTime.parse("2024-01-01T00:00:00Z")) + ))).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + mockMvc.perform(MockMvcRequestBuilders.post(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isCreated()); + + /* If the json is invalid, the corresponding status code is returned */ reset(clusterUtil); when(clusterUtil.checkGroupClusterCreateJson(any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); @@ -158,13 +187,27 @@ public void testGetCluster() throws Exception { String url = ApiRoutes.CLUSTER_BASE_PATH + "/" + groupClusterEntity.getId(); /* If the user has acces to the cluster and it isn't an individual cluster, the cluster is returned */ - when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity)).thenReturn(groupClusterJson); + /* User is not an admin, studentNumber should be hidden */ + when(courseUtil.getCourseIfAdmin(courseEntity.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", courseEntity)); + when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity, true)).thenReturn(groupClusterJson); when(clusterUtil.getGroupClusterEntityIfNotIndividual(groupClusterEntity.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(groupClusterJson))); + verify(entityToJsonConverter, times(1)).clusterEntityToClusterJson(groupClusterEntity, true); + + /* User is an admin, studentNumber should be visible */ + when(courseUtil.getCourseIfAdmin(courseEntity.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", courseEntity)); + when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity, false)).thenReturn(groupClusterJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(groupClusterJson))); + + verify(entityToJsonConverter, times(1)).clusterEntityToClusterJson(groupClusterEntity, false); + /* If any check fails, the corresponding status code is returned */ when(clusterUtil.getGroupClusterEntityIfNotIndividual(anyLong(), any())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); mockMvc.perform(MockMvcRequestBuilders.get(url)) @@ -175,7 +218,7 @@ public void testGetCluster() throws Exception { @Test public void testUpdateCluster() throws Exception { String url = ApiRoutes.CLUSTER_BASE_PATH + "/" + groupClusterEntity.getId(); - String request = "{\"name\": \"newclustername\", \"capacity\": 22}"; + String request = "{\"name\": \"newclustername\", \"capacity\": 22, \"lockGroupsAfter\": \"2024-01-01T00:00:00Z\"}"; String originalname = groupClusterEntity.getName(); Integer originalcapacity = groupClusterEntity.getMaxSize(); /* If the user is an admin of the cluster, the cluster isn't individual and the json is valid, the cluster is updated */ @@ -183,12 +226,12 @@ public void testUpdateCluster() throws Exception { when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); when(clusterUtil.checkGroupClusterUpdateJson( - argThat(json -> json.getName().equals("newclustername") && json.getCapacity().equals(22)) + argThat(json -> json.getName().equals("newclustername") && json.getCapacity().equals(22) && json.getLockGroupsAfter().equals(OffsetDateTime.parse("2024-01-01T00:00:00Z"))) )).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); copy.setName("newclustername"); - GroupClusterJson updated = new GroupClusterJson(1L, "newclustername", 20, 5, OffsetDateTime.now(), Collections.emptyList(), ""); + GroupClusterJson updated = new GroupClusterJson(1L, "newclustername", 20, 5, OffsetDateTime.now(), Collections.emptyList(), null, ""); when(groupClusterRepository.save(groupClusterEntity)).thenReturn(copy); - when(entityToJsonConverter.clusterEntityToClusterJson(copy)).thenReturn(updated); + when(entityToJsonConverter.clusterEntityToClusterJson(copy, false)).thenReturn(updated); mockMvc.perform(MockMvcRequestBuilders.put(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) @@ -315,10 +358,11 @@ public void testPatchCluster() throws Exception { when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); when(clusterUtil.checkGroupClusterUpdateJson( - argThat(json -> json.getName() == groupClusterEntity.getName() && json.getCapacity() == groupClusterEntity.getMaxSize()) + argThat(json -> Objects.equals(json.getName(), groupClusterEntity.getName()) + && json.getCapacity() == groupClusterEntity.getMaxSize()) )).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); when(groupClusterRepository.save(groupClusterEntity)).thenReturn(groupClusterEntity); - when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity)).thenReturn(groupClusterJson); + when(entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity, false)).thenReturn(groupClusterJson); mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) @@ -329,7 +373,7 @@ public void testPatchCluster() throws Exception { assertEquals(originalcapacity, groupClusterEntity.getMaxSize()); /* If fields are not null they are updated */ - request = "{\"name\": \"newclustername\", \"capacity\": 22}"; + request = "{\"name\": \"newclustername\", \"capacity\": 22, \"lockGroupsAfter\": \"2024-01-01T00:00:00Z\"}"; reset(clusterUtil); when(clusterUtil.getGroupClusterEntityIfAdminAndNotIndividual(groupClusterEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupClusterEntity)); @@ -337,9 +381,9 @@ public void testPatchCluster() throws Exception { when(clusterUtil.checkGroupClusterUpdateJson( argThat(json -> json.getName().equals("newclustername") && json.getCapacity().equals(22)) )).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - GroupClusterJson updated = new GroupClusterJson(1L, "newclustername", 22, 5, OffsetDateTime.now(), Collections.emptyList(), ""); + GroupClusterJson updated = new GroupClusterJson(1L, "newclustername", 22, 5, OffsetDateTime.now(), Collections.emptyList(), null, ""); when(groupClusterRepository.save(groupClusterEntity)).thenReturn(copy); - when(entityToJsonConverter.clusterEntityToClusterJson(copy)).thenReturn(updated); + when(entityToJsonConverter.clusterEntityToClusterJson(copy, false)).thenReturn(updated); mockMvc.perform(MockMvcRequestBuilders.patch(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) @@ -348,6 +392,7 @@ public void testPatchCluster() throws Exception { .andExpect(content().json(objectMapper.writeValueAsString(updated))); assertNotEquals(originalname, groupClusterEntity.getName()); assertNotEquals(originalcapacity, groupClusterEntity.getMaxSize()); + assertNotNull(groupClusterEntity.getLockGroupsAfter()); /* If the json is invalid, the corresponding status code is returned */ reset(clusterUtil); @@ -397,7 +442,7 @@ public void testCreateGroupForCluster() throws Exception { when(groupRepository.save(argThat( group -> group.getName().equals("test") && group.getClusterId() == groupClusterEntity.getId() ))).thenReturn(groupEntity); - when(entityToJsonConverter.groupEntityToJson(groupEntity)).thenReturn(groupJson); + when(entityToJsonConverter.groupEntityToJson(groupEntity, false)).thenReturn(groupJson); mockMvc.perform(MockMvcRequestBuilders.post(url) .contentType(MediaType.APPLICATION_JSON) .content(request)) diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ControllerTest.java index b506e8b6..c0e6a073 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ControllerTest.java @@ -47,7 +47,7 @@ public class ControllerTest { public void testSetUp() { MockitoAnnotations.openMocks(this); - User user = new User("displayName", "firstName", "lastName", "email", "test"); + User user = new User("displayName", "firstName", "lastName", "email", "test", "studentnummer"); Auth authUser = new Auth(user, new ArrayList<>()); SecurityContextHolder.getContext().setAuthentication(authUser); @@ -57,7 +57,8 @@ public void testSetUp() { user.lastName, user.email, UserRole.teacher, - user.oid + user.oid, + "studentnummer" ); mockUser.setId(1L); authUser.setUserEntity(mockUser); diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/CourseControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/CourseControllerTest.java index f80af245..a712c982 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/CourseControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/CourseControllerTest.java @@ -112,7 +112,7 @@ public void setup() { activeCourse.getId(), activeCourse.getName(), activeCourse.getDescription(), - new UserReferenceJson("", "", 0L), + new UserReferenceJson("", "", 0L, ""), new ArrayList<>(), "", "", @@ -308,7 +308,7 @@ public void testUpdateCourse() throws Exception { activeCourse.getId(), "test", "description", - new UserReferenceJson("", "", 0L), + new UserReferenceJson("", "", 0L, ""), new ArrayList<>(), "", "", @@ -403,7 +403,7 @@ public void testPatchCourse() throws Exception { activeCourse.getId(), "test", "description2", - new UserReferenceJson("", "", 0L), + new UserReferenceJson("", "", 0L, ""), new ArrayList<>(), "", "", @@ -626,7 +626,8 @@ public void testGetProjectsByCourseId() throws Exception { true, new ProjectProgressJson(1, 1), 1L, - 1L + 1L, + OffsetDateTime.now() ); /* If user is in course, return projects */ when(courseUtil.getCourseIfUserInCourse(activeCourse.getId(),getMockUser())) @@ -831,7 +832,7 @@ public void testAddCourseMember() throws Exception { String requestStringAdmin = "{\"userId\": 1, \"relation\": \"course_admin\"}"; String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/members"; CourseUserEntity courseUser = new CourseUserEntity(activeCourse.getId(), 1, CourseRelation.enrolled); - UserEntity user = new UserEntity("name", "surname", "email", UserRole.teacher, "id"); + UserEntity user = new UserEntity("name", "surname", "email", UserRole.teacher, "id", ""); /* If all checks succeed, return 201 */ when(courseUtil.canUpdateUserInCourse( @@ -907,7 +908,7 @@ public void testUpdateCourseMember() throws Exception { String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/members/" + userId; String request = "{\"relation\": \"enrolled\"}"; String adminRequest = "{\"relation\": \"course_admin\"}"; - UserEntity user = new UserEntity("name", "surname", "email", UserRole.teacher, "id"); + UserEntity user = new UserEntity("name", "surname", "email", UserRole.teacher, "id", ""); CourseUserEntity enrolledUser = new CourseUserEntity(activeCourse.getId(), userId, CourseRelation.enrolled); CourseUserEntity adminUser = new CourseUserEntity(activeCourse.getId(), userId, CourseRelation.course_admin); /* If all checks succeed, 200 gets returned */ @@ -1004,23 +1005,35 @@ public void testUpdateCourseMember() throws Exception { @Test public void testGetCourseMembers() throws Exception { CourseUserEntity courseUserEntity = new CourseUserEntity(1L, 1L, CourseRelation.enrolled); - UserEntity user = new UserEntity("name", "surname", "email", UserRole.teacher, "id"); + UserEntity user = new UserEntity("name", "surname", "email", UserRole.teacher, "id", ""); UserReferenceWithRelation userJson = new UserReferenceWithRelation( - new UserReferenceJson("name", "surname", 1L), + new UserReferenceJson("name", "surname", 1L, ""), ""+CourseRelation.enrolled ); List userList = List.of(courseUserEntity); String url = ApiRoutes.COURSE_BASE_PATH + "/" + activeCourse.getId() + "/members"; /* If user is in course, return members */ when(courseUtil.getCourseIfUserInCourse(activeCourseJson.courseId(), getMockUser())) - .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(activeCourse, CourseRelation.course_admin))); + .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(activeCourse, CourseRelation.enrolled))); when(courseUserRepository.findAllMembers(activeCourseJson.courseId())).thenReturn(userList); when(userUtil.getUserIfExists(courseUserEntity.getUserId())).thenReturn(user); - when(entityToJsonConverter.userEntityToUserReferenceWithRelation(user, CourseRelation.enrolled)).thenReturn(userJson); + /* User is enrolled so studentNumber should be hidden */ + when(entityToJsonConverter.userEntityToUserReferenceWithRelation(user, CourseRelation.enrolled, true)).thenReturn(userJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + verify(entityToJsonConverter, times(1)).userEntityToUserReferenceWithRelation(user, CourseRelation.enrolled, true); + + /* If user is admin studentNumber should be visible */ + when(courseUtil.getCourseIfUserInCourse(activeCourseJson.courseId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(activeCourse, CourseRelation.course_admin))); + when(entityToJsonConverter.userEntityToUserReferenceWithRelation(user, CourseRelation.enrolled, false)).thenReturn(userJson); mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(List.of(userJson)))); + verify(entityToJsonConverter, times(1)).userEntityToUserReferenceWithRelation(user, CourseRelation.enrolled, false); /* If user doesn't get found it gets filtered out */ when(userUtil.getUserIfExists(anyLong())).thenReturn(null); @@ -1106,7 +1119,7 @@ public void testCopyCourse() throws Exception { 2L, "name", "description", - new UserReferenceJson("", "", 0L), + new UserReferenceJson("", "", 0L, ""), new ArrayList<>(), "", "", diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupControllerTest.java index 798e0d89..76ef9110 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupControllerTest.java @@ -29,6 +29,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -74,11 +76,26 @@ public void testGetGroupById() throws Exception { .thenReturn(new CheckResult<>(HttpStatus.OK, "", groupEntity)); when(groupUtil.canGetGroup(groupEntity.getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); - when(entityToJsonConverter.groupEntityToJson(groupEntity)).thenReturn(groupJson); + /* User is admin, student number should not be hidden */ + when(groupUtil.isAdminOfGroup(groupEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(entityToJsonConverter.groupEntityToJson(groupEntity, false)).thenReturn(groupJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string(objectMapper.writeValueAsString(groupJson))); + verify(entityToJsonConverter, times(1)).groupEntityToJson(groupEntity, false); + + /* User is not admin, student number should be hidden */ + when(groupUtil.isAdminOfGroup(groupEntity.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + when(entityToJsonConverter.groupEntityToJson(groupEntity, true)).thenReturn(groupJson); mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().string(objectMapper.writeValueAsString(groupJson))); + verify(entityToJsonConverter, times(1)).groupEntityToJson(groupEntity, true); + /* If the user doesn't have acces to group, return forbidden */ when(groupUtil.canGetGroup(anyLong(), any())) diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupMembersControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupMembersControllerTest.java index 70fd1ac9..b3074b26 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupMembersControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/GroupMembersControllerTest.java @@ -54,12 +54,12 @@ public class GroupMembersControllerTest extends ControllerTest { @BeforeEach public void setup() { setUpController(groupMemberController); - userEntity = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); + userEntity = new UserEntity("name", "surname", "email", UserRole.student, "azureid", ""); userEntity.setId(5L); - userEntity2 = new UserEntity("name2", "surname2", "email2", UserRole.student, "azureid2"); + userEntity2 = new UserEntity("name2", "surname2", "email2", UserRole.student, "azureid2", ""); userEntity2.setId(6L); - userReferenceJson = new UserReferenceJson(userEntity.getName(), userEntity.getEmail(), userEntity.getId()); - userReferenceJson2 = new UserReferenceJson(userEntity2.getName(), userEntity2.getEmail(), userEntity2.getId()); + userReferenceJson = new UserReferenceJson(userEntity.getName(), userEntity.getEmail(), userEntity.getId(), ""); + userReferenceJson2 = new UserReferenceJson(userEntity2.getName(), userEntity2.getEmail(), userEntity2.getId(), ""); } @Test @@ -117,8 +117,8 @@ public void testAddMemberToGroup() throws Exception { .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); when(groupMemberRepository.findAllMembersByGroupId(groupId)) .thenReturn(List.of(userEntity, userEntity2)); - when(entityToJsonConverter.userEntityToUserReference(userEntity)).thenReturn(userReferenceJson); - when(entityToJsonConverter.userEntityToUserReference(userEntity2)).thenReturn(userReferenceJson2); + when(entityToJsonConverter.userEntityToUserReference(userEntity, false)).thenReturn(userReferenceJson); + when(entityToJsonConverter.userEntityToUserReference(userEntity2, false)).thenReturn(userReferenceJson2); mockMvc.perform(MockMvcRequestBuilders.post(url)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) @@ -141,15 +141,15 @@ public void testAddMemberToGroup() throws Exception { @Test public void testAddMemberToGroupInferred() throws Exception { String url = ApiRoutes.GROUP_MEMBER_BASE_PATH.replace("{groupid}", ""+groupId); - UserReferenceJson mockUserJson = new UserReferenceJson(getMockUser().getName(), getMockUser().getEmail(), getMockUser().getId()); + UserReferenceJson mockUserJson = new UserReferenceJson(getMockUser().getName(), getMockUser().getEmail(), getMockUser().getId(), getMockUser().getStudentNumber()); /* If all checks succeed, the user is added to the group */ when(groupUtil.canAddUserToGroup(groupId, getMockUser().getId(), getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); when(groupMemberRepository.findAllMembersByGroupId(groupId)) .thenReturn(List.of(getMockUser(), userEntity2)); - when(entityToJsonConverter.userEntityToUserReference(getMockUser())).thenReturn(mockUserJson); - when(entityToJsonConverter.userEntityToUserReference(userEntity2)).thenReturn(userReferenceJson2); + when(entityToJsonConverter.userEntityToUserReference(getMockUser(), true)).thenReturn(mockUserJson); + when(entityToJsonConverter.userEntityToUserReference(userEntity2, true)).thenReturn(userReferenceJson2); mockMvc.perform(MockMvcRequestBuilders.post(url)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) @@ -174,8 +174,10 @@ public void testFindAllMembersByGroupId() throws Exception { List members = List.of(userEntity, userEntity2); List userReferenceJsons = List.of(userReferenceJson, userReferenceJson2); when(groupMemberRepository.findAllMembersByGroupId(groupId)).thenReturn(members); - when(entityToJsonConverter.userEntityToUserReference(userEntity)).thenReturn(userReferenceJson); - when(entityToJsonConverter.userEntityToUserReference(userEntity2)).thenReturn(userReferenceJson2); + /* User is admin of group so don't hide studentNumbers */ + when(groupUtil.isAdminOfGroup(groupId, getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(entityToJsonConverter.userEntityToUserReference(userEntity, false)).thenReturn(userReferenceJson); + when(entityToJsonConverter.userEntityToUserReference(userEntity2, false)).thenReturn(userReferenceJson2); /* If user can get group return list of members */ when(groupUtil.canGetGroup(groupId, getMockUser())) @@ -185,6 +187,21 @@ public void testFindAllMembersByGroupId() throws Exception { .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(userReferenceJsons))); + verify(entityToJsonConverter, times(1)).userEntityToUserReference(userEntity, false); + verify(entityToJsonConverter, times(1)).userEntityToUserReference(userEntity2, false); + + /* If user isn't admin, studentNumbers should be hidden */ + when(groupUtil.isAdminOfGroup(groupId, getMockUser())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + when(entityToJsonConverter.userEntityToUserReference(userEntity, true)).thenReturn(userReferenceJson); + when(entityToJsonConverter.userEntityToUserReference(userEntity2, true)).thenReturn(userReferenceJson2); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(userReferenceJsons))); + + verify(entityToJsonConverter, times(1)).userEntityToUserReference(userEntity, true); + verify(entityToJsonConverter, times(1)).userEntityToUserReference(userEntity2, true); + /* If use can't get group return corresponding status */ when(groupUtil.canGetGroup(groupId, getMockUser())) .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ProjectControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ProjectControllerTest.java index e95b0b5b..e17ae6ae 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/ProjectControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/ProjectControllerTest.java @@ -35,6 +35,8 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -118,7 +120,8 @@ void setUp() { projectEntity.isVisible(), new ProjectProgressJson(0, 0), 1L, - groupClusterId + groupClusterId, + OffsetDateTime.now() ); projectEntity2 = new ProjectEntity( @@ -144,7 +147,8 @@ void setUp() { projectEntity2.isVisible(), new ProjectProgressJson(0, 0), 1L, - groupClusterId + groupClusterId, + OffsetDateTime.now() ); } @@ -180,17 +184,46 @@ void testGetProjects() throws Exception { .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(userProjectsJson))); - /* If project is visible and role enrolled, don't return it */ + /* If project isn't visible and role enrolled, don't return it */ + projectEntity2.setVisible(false); + userProjectsJson = new UserProjectsJson( + Collections.emptyList(), + List.of(projectResponseJson) + ); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(userProjectsJson))); + + /* If project isn't visible but visibleAfter is passed, update visibility */ + projectEntity2.setVisibleAfter(OffsetDateTime.now().minusDays(1)); + userProjectsJson = new UserProjectsJson( + List.of(projectJsonWithStatus), + List.of(projectResponseJson) + ); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(userProjectsJson))); + + verify(projectRepository, times(1)).save(projectEntity2); + assertTrue(projectEntity2.isVisible()); + + /* If project isn't visible and visibleAfter is in the future, don't return it */ projectEntity2.setVisible(false); + projectEntity2.setVisibleAfter(OffsetDateTime.now().plusDays(1)); userProjectsJson = new UserProjectsJson( Collections.emptyList(), List.of(projectResponseJson) ); + mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(userProjectsJson))); + assertFalse(projectEntity2.isVisible()); + /* If a coursecheck fails, return corresponding status */ when(courseUtil.getCourseIfUserInCourse(courseEntity.getId(), getMockUser())).thenReturn( new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null) @@ -222,6 +255,22 @@ void testGetProject() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isNotFound()); + /* if visibleAfter is passed, update visibility */ + projectEntity.setVisibleAfter(OffsetDateTime.now().minusDays(1)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()); + + verify(projectRepository, times(1)).save(projectEntity); + assertTrue(projectEntity.isVisible()); + + /* If visibleAfter is in the future, return 404 */ + projectEntity.setVisible(false); + projectEntity.setVisibleAfter(OffsetDateTime.now().plusDays(1)); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isNotFound()); + + assertFalse(projectEntity.isVisible()); + /* If user is not enrolled and project not visible, return project */ when(courseUtil.getCourseIfUserInCourse(projectEntity.getCourseId(), getMockUser())).thenReturn( new CheckResult<>(HttpStatus.OK, "", new Pair<>(courseEntity, CourseRelation.course_admin)) @@ -249,13 +298,15 @@ void testGetProject() throws Exception { @Test public void testCreateProject() throws Exception { String url = ApiRoutes.COURSE_BASE_PATH + "/" + courseEntity.getId() + "/projects"; + projectEntity.setVisibleAfter(OffsetDateTime.now().plusDays(1)); String request = "{\n" + " \"name\": \"" + projectEntity.getName() + "\",\n" + " \"description\": \"" + projectEntity.getDescription() + "\",\n" + " \"groupClusterId\": " + projectEntity.getGroupClusterId() + ",\n" + " \"visible\": " + projectEntity.isVisible() + ",\n" + " \"maxScore\": " + projectEntity.getMaxScore() + ",\n" + - " \"deadline\": \"" + projectEntity.getDeadline() + "\"\n" + + " \"deadline\": \"" + projectEntity.getDeadline() + "\",\n" + + " \"visibleAfter\": \"" + projectEntity.getVisibleAfter() + "\"\n" + "}"; /* If all checks succeed, create course */ @@ -287,6 +338,7 @@ public void testCreateProject() throws Exception { && project.isVisible().equals(projectEntity.isVisible()) && project.getMaxScore().equals(projectEntity.getMaxScore()) && project.getDeadline().toInstant().equals(projectEntity.getDeadline().toInstant()) + && project.getVisibleAfter().toInstant().equals(projectEntity.getVisibleAfter().toInstant()) )); /* If groupClusterId is not provided, use invalid groupClusterId */ @@ -387,7 +439,8 @@ void testPutProjectById() throws Exception { false, new ProjectProgressJson(0, 0), 1L, - groupClusterId * 4 + groupClusterId * 4, + OffsetDateTime.now() ); /* If all checks pass, update and return the project */ when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( @@ -426,6 +479,48 @@ void testPutProjectById() throws Exception { projectEntity.setMaxScore(orginalMaxScore); projectEntity.setDeadline(orginalDeadline); + /* If visible after is passed, update visibility */ + projectEntity.setVisibleAfter(OffsetDateTime.now().minusDays(1)); + request = "{\n" + + " \"name\": \"" + "UpdatedName" + "\",\n" + + " \"description\": \"" + "UpdatedDescription" + "\",\n" + + " \"groupClusterId\": " + groupClusterId * 4 + ",\n" + + " \"visible\": " + false + ",\n" + + " \"maxScore\": " + (projectEntity.getMaxScore() + 33) + ",\n" + + " \"deadline\": \"" + newDeadline + "\",\n" + + " \"visibleAfter\": \"" + OffsetDateTime.now().minusDays(1) + "\"\n" + + "}"; + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)); + + verify(projectRepository, times(2)).save(projectEntity); + assertTrue(projectEntity.isVisible()); + + /* If visible after isn't passed, don't update visibility */ + projectEntity.setVisible(false); + request = "{\n" + + " \"name\": \"" + "UpdatedName" + "\",\n" + + " \"description\": \"" + "UpdatedDescription" + "\",\n" + + " \"groupClusterId\": " + groupClusterId * 4 + ",\n" + + " \"visible\": " + false + ",\n" + + " \"maxScore\": " + (projectEntity.getMaxScore() + 33) + ",\n" + + " \"deadline\": \"" + newDeadline + "\",\n" + + " \"visibleAfter\": \"" + OffsetDateTime.now().plusDays(1) + "\"\n" + + "}"; + mockMvc.perform(MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_JSON) + .content(request)); + + assertFalse(projectEntity.isVisible()); + + projectEntity.setName(orginalName); + projectEntity.setDescription(orginalDescription); + projectEntity.setGroupClusterId(orginalGroupClusterId); + projectEntity.setVisible(orginalVisible); + projectEntity.setMaxScore(orginalMaxScore); + projectEntity.setDeadline(orginalDeadline); + /* If groupClusterId is not provided, use invalid groupClusterId */ reset(projectUtil); request = "{\n" + @@ -461,9 +556,11 @@ void testPutProjectById() throws Exception { assertEquals(projectEntity.isVisible(), false); assertEquals(projectEntity.getMaxScore(), orginalMaxScore + 33); assertEquals(projectEntity.getDeadline().toInstant(), newDeadline.toInstant()); - verify(projectRepository, times(2)).save(projectEntity); + verify(projectRepository, times(4)).save(projectEntity); projectEntity.setGroupClusterId(orginalGroupClusterId); + + /* If project json is invalid, return corresponding status */ reset(projectUtil); when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( @@ -504,7 +601,8 @@ void testPatchProjectById() throws Exception { " \"groupClusterId\": " + groupClusterId * 4 + ",\n" + " \"visible\": " + false + ",\n" + " \"maxScore\": " + (projectEntity.getMaxScore() + 33) + ",\n" + - " \"deadline\": \"" + newDeadline + "\"\n" + + " \"deadline\": \"" + newDeadline + "\",\n" + + " \"visibleAfter\": \"" + OffsetDateTime.now().plusDays(1) + "\"\n" + "}"; String orginalName = projectEntity.getName(); String orginalDescription = projectEntity.getDescription(); @@ -524,7 +622,8 @@ void testPatchProjectById() throws Exception { false, new ProjectProgressJson(0, 0), 1L, - groupClusterId * 4 + groupClusterId * 4, + OffsetDateTime.now() ); /* If all checks pass, update and return the project */ when(projectUtil.getProjectIfAdmin(projectEntity.getId(), getMockUser())).thenReturn( @@ -647,7 +746,7 @@ void testPatchProjectById() throws Exception { } @Test - void getGroupsOfProject() throws Exception { + void testGetGroupsOfProject() throws Exception { String url = ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId() + "/groups"; GroupEntity groupEntity = new GroupEntity("groupName", 1L); long groupId = 83L; @@ -661,12 +760,26 @@ void getGroupsOfProject() throws Exception { when(clusterUtil.isIndividualCluster(projectEntity.getGroupClusterId())).thenReturn(false); when(projectRepository.findGroupIdsByProjectId(projectEntity.getId())).thenReturn(List.of(groupId)); when(grouprRepository.findById(groupId)).thenReturn(Optional.of(groupEntity)); - when(entityToJsonConverter.groupEntityToJson(groupEntity)).thenReturn(groupJson); + /* User is admin so studentNumber shouldn't be hidden */ + when(projectUtil.isProjectAdmin(projectEntity.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(entityToJsonConverter.groupEntityToJson(groupEntity, false)).thenReturn(groupJson); mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(List.of(groupJson)))); + verify(entityToJsonConverter, times(1)).groupEntityToJson(groupEntity, false); + + /* If user is not admin, studentNumber should be hidden */ + when(projectUtil.isProjectAdmin(projectEntity.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + when(entityToJsonConverter.groupEntityToJson(groupEntity, true)).thenReturn(groupJson); + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(groupJson)))); + + verify(entityToJsonConverter, times(1)).groupEntityToJson(groupEntity, true); + /* If inidividual cluster return no content */ when(clusterUtil.isIndividualCluster(projectEntity.getGroupClusterId())).thenReturn(true); mockMvc.perform(MockMvcRequestBuilders.get(url)) diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/SubmissionControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/SubmissionControllerTest.java index 21b496ae..58e92fa5 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/SubmissionControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/SubmissionControllerTest.java @@ -19,16 +19,24 @@ import com.ugent.pidgeon.postgre.models.types.DockerTestType; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import com.ugent.pidgeon.postgre.repository.*; import com.ugent.pidgeon.util.*; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; +import java.util.logging.Logger; import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -43,6 +51,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import java.time.OffsetDateTime; @@ -54,6 +63,7 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; @@ -112,10 +122,10 @@ public class SubmissionControllerTest extends ControllerTest { public static File createTestFile() throws IOException { // Create a temporary directory - File tempDir = Files.createTempDirectory("test-dir").toFile(); + File tempDir = Files.createTempDirectory("SELAB6CANDELETEtest-dir").toFile(); // Create a temporary file within the directory - File tempFile = File.createTempFile("test-file", ".zip", tempDir); + File tempFile = File.createTempFile("SELAB6CANDELETEtest-file", ".zip", tempDir); // Create some content to write into the zip file String content = "Hello, this is a test file!"; @@ -137,7 +147,7 @@ public static File createTestFile() throws IOException { public void setup() { setUpController(submissionController); - submission = new SubmissionEntity(22, 45, 99L, OffsetDateTime.MIN, true, true); + submission = new SubmissionEntity(22L, 45L, 99L, OffsetDateTime.MIN, true, true); submission.setId(56L); groupIds = List.of(45L); submissionJson = new SubmissionJson( @@ -209,7 +219,7 @@ public void testGetSubmissions() throws Exception { when(projectUtil.isProjectAdmin(submission.getProjectId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); when(projectRepository.findGroupIdsByProjectId(submission.getProjectId())).thenReturn(groupIds); when(groupRepository.findById(groupIds.get(0))).thenReturn(Optional.of(groupEntity)); - when(entityToJsonConverter.groupEntityToJson(groupEntity)).thenReturn(groupJson); + when(entityToJsonConverter.groupEntityToJson(groupEntity, false)).thenReturn(groupJson); when(groupFeedbackRepository.getGroupFeedback(groupEntity.getId(), submission.getProjectId())).thenReturn(groupFeedbackEntity); when(entityToJsonConverter.groupFeedbackEntityToJson(groupFeedbackEntity)).thenReturn(groupFeedbackJson); when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(submission.getProjectId(), groupEntity.getId())).thenReturn(Optional.of(submission)); @@ -219,6 +229,8 @@ public void testGetSubmissions() throws Exception { .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(List.of(lastGroupSubmissionJson)))); + verify(entityToJsonConverter, times(1)).groupEntityToJson(groupEntity, false); + /* no submission */ when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(submission.getProjectId(), groupEntity.getId())).thenReturn(Optional.empty()); lastGroupSubmissionJson.setSubmission(null); @@ -276,17 +288,18 @@ public void testSubmitFile() throws Exception { File file = createTestFile(); try (MockedStatic mockedFileHandler = mockStatic(Filehandler.class)) { mockedFileHandler.when(() -> Filehandler.getSubmissionPath(submission.getProjectId(), groupEntity.getId(), submission.getId())).thenReturn(path); - mockedFileHandler.when(() -> Filehandler.saveSubmission(path, mockMultipartFile)).thenReturn(file); + mockedFileHandler.when(() -> Filehandler.saveFile(path, mockMultipartFile, Filehandler.SUBMISSION_FILENAME)).thenReturn(file); mockedFileHandler.when(() -> Filehandler.getSubmissionArtifactPath(anyLong(), anyLong(), anyLong())).thenReturn(artifactPath); when(testRunner.runStructureTest(any(), eq(testEntity), any())).thenReturn(null); - when(testRunner.runDockerTest(any(), eq(testEntity), eq(artifactPath), any())).thenReturn(null); + when(testRunner.runDockerTest(any(), eq(testEntity), eq(artifactPath), any(), eq(submission.getProjectId()))).thenReturn(null); when(entityToJsonConverter.getSubmissionJson(submission)).thenReturn(submissionJson); when(testRepository.findByProjectId(submission.getProjectId())).thenReturn(Optional.of(testEntity)); when(entityToJsonConverter.getSubmissionJson(submission)).thenReturn(submissionJson); + mockMvc.perform(MockMvcRequestBuilders.multipart(url) .file(mockMultipartFile)) .andExpect(status().isOk()) @@ -295,7 +308,7 @@ public void testSubmitFile() throws Exception { /* assertEquals(DockerTestState.running, submission.getDockerTestState()); */ // This executes too quickly so we can't test this - Thread.sleep(1000); + Thread.sleep(2000); // File repository needs to save again after setting path verify(fileRepository, times(1)).save(argThat( @@ -356,7 +369,7 @@ public void testSubmitFile() throws Exception { testEntity.setDockerImage("dockerImage"); testEntity.setDockerTestScript("dockerTestScript"); DockerOutput dockerOutput = new DockerTestOutput( List.of("dockerFeedback-test"), true); - when(testRunner.runDockerTest(any(), eq(testEntity), eq(artifactPath), any())).thenReturn(dockerOutput); + when(testRunner.runDockerTest(any(), eq(testEntity), eq(artifactPath), any(), eq(submission.getProjectId()))).thenReturn(dockerOutput); submission.setDockerAccepted(false); submission.setDockerFeedback("dockerFeedback-test"); mockMvc.perform(MockMvcRequestBuilders.multipart(url) @@ -365,7 +378,7 @@ public void testSubmitFile() throws Exception { .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(submissionJson))); - Thread.sleep(1000); + Thread.sleep(2000); assertTrue(submission.getDockerAccepted()); assertEquals("dockerFeedback-test", submission.getDockerFeedback()); @@ -380,7 +393,7 @@ public void testSubmitFile() throws Exception { .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(content().json(objectMapper.writeValueAsString(submissionJson))); verify(testRunner, times(0)).runStructureTest(any(), eq(testEntity), any()); - verify(testRunner, times(0)).runDockerTest(any(), eq(testEntity), eq(artifactPath), any()); + verify(testRunner, times(0)).runDockerTest(any(), eq(testEntity), eq(artifactPath), any(), eq(submission.getProjectId())); /* Unexpected error */ reset(fileRepository); @@ -416,6 +429,7 @@ public void testGetSubmissionFile() throws Exception { when(submissionUtil.canGetSubmission(submission.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.OK, "", submission)); when(fileRepository.findById(submission.getFileId())).thenReturn(Optional.of(fileEntity)); mockedFileHandler.when(() -> Filehandler.getFileAsResource(path)).thenReturn(mockedResource); + mockedFileHandler.when(() -> Filehandler.getZipFileAsResponse(path, fileEntity.getName())).thenCallRealMethod(); mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isOk()) @@ -424,23 +438,12 @@ public void testGetSubmissionFile() throws Exception { HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + fileEntity.getName())) .andExpect(content().bytes(mockedResource.getInputStream().readAllBytes())); - /* Resource not found */ - mockedFileHandler.when(() -> Filehandler.getFileAsResource(path)).thenReturn(null); - mockMvc.perform(MockMvcRequestBuilders.get(url)) - .andExpect(status().isNotFound()); /* file not found */ when(fileRepository.findById(submission.getFileId())).thenReturn(Optional.empty()); mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isNotFound()); - /* Unexpected error */ - when(fileRepository.findById(submission.getFileId())).thenReturn(Optional.of(fileEntity)); - mockedFileHandler.reset(); - mockedFileHandler.when(() -> Filehandler.getFileAsResource(path)).thenThrow(new RuntimeException()); - mockMvc.perform(MockMvcRequestBuilders.get(url)) - .andExpect(status().isInternalServerError()); - /* User can't get submission */ when(submissionUtil.canGetSubmission(submission.getId(), getMockUser())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); mockMvc.perform(MockMvcRequestBuilders.get(url)) @@ -527,4 +530,400 @@ public void testGetSubmissionsForGroup() throws Exception { mockMvc.perform(MockMvcRequestBuilders.get(url)) .andExpect(status().isIAmATeapot()); } + + @Test + public void testGetAdminSubmissions() { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + submission.getProjectId() + "/adminsubmissions"; + + /* all checks succeed */ + when(projectUtil.isProjectAdmin(submission.getProjectId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + when(submissionRepository.findAdminSubmissionsByProjectId(submission.getProjectId())) + .thenReturn(List.of(submission)); + when(entityToJsonConverter.getSubmissionJson(submission)).thenReturn(submissionJson); + + try { + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(List.of(submissionJson)))); + } catch (Exception e) { + e.printStackTrace(); + } + + /* No submissions */ + when(submissionRepository.findAdminSubmissionsByProjectId(submission.getProjectId())) + .thenReturn(List.of()); + + try { + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json("[]")); + } catch (Exception e) { + e.printStackTrace(); + } + + /* User can't get project */ + when(projectUtil.isProjectAdmin(submission.getProjectId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + + try { + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Test + public void testGetSubmissionsFiles() { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + submission.getProjectId() + "/submissions/files"; + + /* Create temp zip file for submission */ + File file = null; + try { + file = createTestFile(); + } catch (IOException e) { + e.printStackTrace(); + } + assertNotNull(file); + fileEntity.setPath(file.getAbsolutePath()); + + + /* All checks succeed */ + when(projectUtil.isProjectAdmin(submission.getProjectId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + + when(projectRepository.findGroupIdsByProjectId(submission.getProjectId())).thenReturn(groupIds); + when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(submission.getProjectId(), groupEntity.getId())).thenReturn(Optional.of(submission)); + when(fileRepository.findById(submission.getFileId())).thenReturn(Optional.of(fileEntity)); + + try { + MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=allsubmissions.zip")) + .andReturn(); + + byte[] content = mvcResult.getResponse().getContentAsByteArray(); + + boolean groupzipfound = false; + boolean fileszipfound = false; + /* Check contents of file */ + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(content))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().equals("group-" + submission.getGroupId() + ".zip")) { + groupzipfound = true; + /* Check if there is a zipfile inside the zipfile with name 'files.zip' */ + + // Create a new ByteArrayOutputStream to store the content of the group zip file + ByteArrayOutputStream groupZipContent = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = zis.read(buffer)) != -1) { + groupZipContent.write(buffer, 0, bytesRead); + } + + byte[] groupZipContentBytes = groupZipContent.toByteArray(); + + ByteArrayInputStream groupZipByteStream = new ByteArrayInputStream(groupZipContentBytes); + + // Create a new ZipInputStream using the ByteArrayInputStream + try (ZipInputStream groupZipInputStream = new ZipInputStream(groupZipByteStream)) { + ZipEntry groupEntry; + while ((groupEntry = groupZipInputStream.getNextEntry()) != null) { + if (groupEntry.getName().equals("files.zip")) { + fileszipfound = true; + } + } + } + } + } + } + assertTrue(groupzipfound); + assertTrue(fileszipfound); + + } catch (Exception e) { + e.printStackTrace(); + } + + /* With arifact */ + url += "?artifacts=true"; + // Create artifact tempfile + File artifactFile = null; + try { + artifactFile = createTestFile(); + } catch (IOException e) { + e.printStackTrace(); + } + assertNotNull(artifactFile); + + try (MockedStatic mockedFileHandler = mockStatic(Filehandler.class)) { + mockedFileHandler.when(() -> Filehandler. + getSubmissionArtifactPath(submission.getProjectId(), groupEntity.getId(), submission.getId())) + .thenReturn(Path.of(artifactFile.getAbsolutePath())); + mockedFileHandler.when(() -> Filehandler.addExistingZip(any(), any(), any())) + .thenCallRealMethod(); + mockedFileHandler.when(() -> Filehandler.getZipFileAsResponse(any(), any())) + .thenCallRealMethod(); + mockedFileHandler.when(() -> Filehandler.getFileAsResource(any())) + .thenCallRealMethod(); + + MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=allsubmissions.zip")) + .andReturn(); + + byte[] content = mvcResult.getResponse().getContentAsByteArray(); + + boolean groupzipfound = false; + boolean fileszipfound = false; + boolean artifactzipfound = false; + + /* Check contents of file */ + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(content))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().equals("group-" + submission.getGroupId() + ".zip")) { + groupzipfound = true; + /* Check if there is a zipfile inside the zipfile with name 'files.zip' */ + + // Create a new ByteArrayOutputStream to store the content of the group zip file + ByteArrayOutputStream groupZipContent = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = zis.read(buffer)) != -1) { + groupZipContent.write(buffer, 0, bytesRead); + } + + byte[] groupZipContentBytes = groupZipContent.toByteArray(); + + ByteArrayInputStream groupZipByteStream = new ByteArrayInputStream(groupZipContentBytes); + + // Create a new ZipInputStream using the ByteArrayInputStream + try (ZipInputStream groupZipInputStream = new ZipInputStream(groupZipByteStream)) { + ZipEntry groupEntry; + while ((groupEntry = groupZipInputStream.getNextEntry()) != null) { + if (groupEntry.getName().equals("files.zip")) { + fileszipfound = true; + } else if (groupEntry.getName().equals("artifacts.zip")) { + artifactzipfound = true; + } + } + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } + assertTrue(groupzipfound); + assertTrue(fileszipfound); + assertTrue(artifactzipfound); + } catch (Exception e) { + e.printStackTrace(); + } + + /* With artifact but no artifact file, should just return the zip without an artifacts.zip */ + try (MockedStatic mockedFileHandler = mockStatic(Filehandler.class)) { + mockedFileHandler.when(() -> Filehandler. + getSubmissionArtifactPath(submission.getProjectId(), groupEntity.getId(), submission.getId())) + .thenReturn(Path.of("nonexistent")); + mockedFileHandler.when(() -> Filehandler.addExistingZip(any(), any(), any())) + .thenCallRealMethod(); + mockedFileHandler.when(() -> Filehandler.getZipFileAsResponse(any(), any())) + .thenCallRealMethod(); + mockedFileHandler.when(() -> Filehandler.getFileAsResource(any())) + .thenCallRealMethod(); + + MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=allsubmissions.zip")) + .andReturn(); + + byte[] content = mvcResult.getResponse().getContentAsByteArray(); + + boolean groupzipfound = false; + boolean fileszipfound = false; + boolean artifactzipfound = false; + + /* Check contents of file */ + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(content))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().equals("group-" + submission.getGroupId() + ".zip")) { + groupzipfound = true; + /* Check if there is a zipfile inside the zipfile with name 'files.zip' */ + + // Create a new ByteArrayOutputStream to store the content of the group zip file + ByteArrayOutputStream groupZipContent = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = zis.read(buffer)) != -1) { + groupZipContent.write(buffer, 0, bytesRead); + } + + byte[] groupZipContentBytes = groupZipContent.toByteArray(); + + ByteArrayInputStream groupZipByteStream = new ByteArrayInputStream(groupZipContentBytes); + + // Create a new ZipInputStream using the ByteArrayInputStream + try (ZipInputStream groupZipInputStream = new ZipInputStream(groupZipByteStream)) { + ZipEntry groupEntry; + while ((groupEntry = groupZipInputStream.getNextEntry()) != null) { + if (groupEntry.getName().equals("files.zip")) { + fileszipfound = true; + } else if (groupEntry.getName().equals("artifacts.zip")) { + artifactzipfound = true; + } + } + } + } + } + } catch (Exception e) { + e.printStackTrace(); + } + assertTrue(groupzipfound); + assertTrue(fileszipfound); + assertFalse(artifactzipfound); + + } catch (Exception e) { + e.printStackTrace(); + } + + /* With artifact parameter false */ + url = url.replace("?artifacts=true", "?artifacts=false"); + + try { + + + MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=allsubmissions.zip")) + .andReturn(); + + byte[] content = mvcResult.getResponse().getContentAsByteArray(); + + boolean groupzipfound = false; + boolean fileszipfound = false; + boolean artifactzipfound = false; + /* Check contents of file */ + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(content))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().equals("group-" + submission.getGroupId() + ".zip")) { + groupzipfound = true; + /* Check if there is a zipfile inside the zipfile with name 'files.zip' */ + + // Create a new ByteArrayOutputStream to store the content of the group zip file + ByteArrayOutputStream groupZipContent = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = zis.read(buffer)) != -1) { + groupZipContent.write(buffer, 0, bytesRead); + } + + byte[] groupZipContentBytes = groupZipContent.toByteArray(); + + ByteArrayInputStream groupZipByteStream = new ByteArrayInputStream(groupZipContentBytes); + + // Create a new ZipInputStream using the ByteArrayInputStream + try (ZipInputStream groupZipInputStream = new ZipInputStream(groupZipByteStream)) { + ZipEntry groupEntry; + while ((groupEntry = groupZipInputStream.getNextEntry()) != null) { + if (groupEntry.getName().equals("files.zip")) { + fileszipfound = true; + } else if (groupEntry.getName().equals("artifacts.zip")) { + artifactzipfound = true; + } + } + } + } + } + } + assertTrue(groupzipfound); + assertTrue(fileszipfound); + + } catch (Exception e) { + e.printStackTrace(); + } + + /* File not found, should return empty zip */ + when(fileRepository.findById(submission.getFileId())).thenReturn(Optional.empty()); + + try { + MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=allsubmissions.zip")) + .andReturn(); + + byte[] content = mvcResult.getResponse().getContentAsByteArray(); + + boolean zipfound = false; + /* Check contents of file */ + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(content))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + zipfound = true; + } + } + assertFalse(zipfound); + + } catch (Exception e) { + e.printStackTrace(); + } + + /* Submission not found, should return empty zip */ + when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(submission.getProjectId(), groupEntity.getId())).thenReturn(Optional.empty()); + + try { + MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=allsubmissions.zip")) + .andReturn(); + + byte[] content = mvcResult.getResponse().getContentAsByteArray(); + + boolean zipfound = false; + /* Check contents of file */ + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(content))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + zipfound = true; + } + } + assertFalse(zipfound); + + } catch (Exception e) { + e.printStackTrace(); + } + + /* Not admin */ + when(projectUtil.isProjectAdmin(submission.getProjectId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + + try { + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); + } catch (Exception e) { + e.printStackTrace(); + } + + /* Unexecpted error */ + when(projectUtil.isProjectAdmin(submission.getProjectId(), getMockUser())) + .thenThrow(new RuntimeException()); + + try { + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isInternalServerError()); + } catch (Exception e) { + e.printStackTrace(); + } + } } \ No newline at end of file diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/TestControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/TestControllerTest.java index a360aaff..16664d59 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/TestControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/TestControllerTest.java @@ -4,6 +4,7 @@ import com.ugent.pidgeon.CustomObjectMapper; import com.ugent.pidgeon.model.json.TestJson; import com.ugent.pidgeon.model.json.TestUpdateJson; +import com.ugent.pidgeon.postgre.models.FileEntity; import com.ugent.pidgeon.postgre.models.GroupEntity; import com.ugent.pidgeon.postgre.models.ProjectEntity; import com.ugent.pidgeon.postgre.models.TestEntity; @@ -13,20 +14,28 @@ import com.ugent.pidgeon.util.CheckResult; import com.ugent.pidgeon.util.CommonDatabaseActions; import com.ugent.pidgeon.util.EntityToJsonConverter; +import com.ugent.pidgeon.util.FileUtil; import com.ugent.pidgeon.util.Filehandler; import com.ugent.pidgeon.util.Pair; import com.ugent.pidgeon.util.ProjectUtil; import com.ugent.pidgeon.util.TestUtil; +import java.io.File; +import java.io.FileOutputStream; import java.time.OffsetDateTime; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -45,6 +54,7 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -61,12 +71,17 @@ public class TestControllerTest extends ControllerTest{ private TestRepository testRepository; @Mock private ProjectRepository projectRepository; + @Mock + private FileRepository fileRepository; @Mock private EntityToJsonConverter entityToJsonConverter; @Mock private CommonDatabaseActions commonDatabaseActions; + @Mock + private FileUtil fileUtil; + @InjectMocks private TestController testController; @@ -74,6 +89,8 @@ public class TestControllerTest extends ControllerTest{ private ObjectMapper objectMapper = CustomObjectMapper.createObjectMapper(); + private MockMultipartFile mockMultipartFile; + private FileEntity fileEntity; private ProjectEntity project; private TestEntity test; private TestJson testJson; @@ -106,9 +123,18 @@ public void setup() { test.getDockerImage(), test.getDockerTestScript(), test.getDockerTestTemplate(), - test.getStructureTemplate() + test.getStructureTemplate(), + "extraFilesUrl", + "extraFilesName" + ); + byte[] fileContent = "Your file content".getBytes(); + mockMultipartFile = new MockMultipartFile("file", "filename.txt", + MediaType.TEXT_PLAIN_VALUE, fileContent); + + fileEntity = new FileEntity("name", "dir/name", 1L); + fileEntity.setId(77L); } @Test @@ -132,7 +158,9 @@ public void testUpdateTest() throws Exception { dockerImage, dockerTestScript, dockerTestTemplate, - structureTemplate + structureTemplate, + "extraFilesUrl", + "extraFilesName" ); /* All checks succeed */ when(testUtil.checkForTestUpdate( @@ -141,6 +169,7 @@ public void testUpdateTest() throws Exception { eq(dockerImage), eq(dockerTestScript), eq(dockerTestTemplate), + eq(structureTemplate), eq(HttpMethod.POST) )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(null, project))); @@ -174,7 +203,9 @@ public void testUpdateTest() throws Exception { null, null, null, - null + null, + "extraFilesUrl", + "extraFilesName" ); testUpdateJson = new TestUpdateJson( dockerImageBlank, @@ -189,6 +220,7 @@ public void testUpdateTest() throws Exception { eq(null), eq(null), eq(null), + eq(null), eq(HttpMethod.POST) )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(null, project))); @@ -238,6 +270,7 @@ public void testUpdateTest() throws Exception { eq(dockerImage), eq(dockerTestScript), eq(dockerTestTemplate), + eq(structureTemplate), eq(HttpMethod.POST) )).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); @@ -285,7 +318,9 @@ public void testPutTest() throws Exception { dockerImage, dockerTestScript, dockerTestTemplate, - structureTemplate + structureTemplate, + "extraFilesUrl", + "extraFilesName" ); /* All checks succeed */ when(testUtil.checkForTestUpdate( @@ -294,6 +329,7 @@ public void testPutTest() throws Exception { eq(dockerImage), eq(dockerTestScript), eq(dockerTestTemplate), + eq(structureTemplate), eq(HttpMethod.PUT) )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); @@ -337,7 +373,9 @@ public void testPutTest() throws Exception { null, null, null, - null + null, + "extraFilesUrl", + "extraFilesName" ); reset(testUtil); when(testUtil.checkForTestUpdate( @@ -346,6 +384,7 @@ public void testPutTest() throws Exception { eq(null), eq(null), eq(null), + eq(null), eq(HttpMethod.PUT) )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); @@ -415,6 +454,7 @@ public void testPutTest() throws Exception { eq(dockerImage), eq(dockerTestScript), eq(dockerTestTemplate), + eq(structureTemplate), eq(HttpMethod.PUT) )).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); @@ -448,6 +488,7 @@ public void testGetPatch() throws Exception { eq(dockerImage), eq(null), eq(null), + eq(null), eq(HttpMethod.PATCH) )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); @@ -489,6 +530,7 @@ public void testGetPatch() throws Exception { eq(null), eq(dockerTestScript), eq(null), + eq(null), eq(HttpMethod.PATCH) )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); @@ -521,6 +563,7 @@ public void testGetPatch() throws Exception { eq(null), eq(null), eq(dockerTestTemplate), + eq(null), eq(HttpMethod.PATCH) )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); @@ -553,6 +596,7 @@ public void testGetPatch() throws Exception { eq(null), eq(null), eq(null), + eq(structureTemplate), eq(HttpMethod.PATCH) )).thenReturn(new CheckResult<>(HttpStatus.OK, "",new Pair<>(test, project))); @@ -585,6 +629,7 @@ public void testGetPatch() throws Exception { eq(dockerImage), eq(null), eq(null), + eq(null), eq(HttpMethod.PATCH) )).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); @@ -645,6 +690,7 @@ public void testDeleteTest() throws Exception { eq(null), eq(null), eq(null), + eq(null), eq(HttpMethod.DELETE) )).thenReturn(new CheckResult<>(HttpStatus.OK, "", new Pair<>(test, project))); @@ -666,10 +712,201 @@ public void testDeleteTest() throws Exception { eq(null), eq(null), eq(null), + eq(null), eq(HttpMethod.DELETE) )).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); mockMvc.perform(MockMvcRequestBuilders.delete(url)) .andExpect(status().isIAmATeapot()); } + + public static File createTestFile() throws IOException { + // Create a temporary directory + File tempDir = Files.createTempDirectory("SELAB6CANDELETEtest-dir").toFile(); + + // Create a temporary file within the directory + File tempFile = File.createTempFile("SELAB6CANDELETEtest-file", ".zip", tempDir); + + // Create some content to write into the zip file + String content = "Hello, this is a test file!"; + byte[] bytes = content.getBytes(); + + // Write the content into a file inside the zip file + try (ZipOutputStream zipOut = new ZipOutputStream(new FileOutputStream(tempFile))) { + ZipEntry entry = new ZipEntry("test.txt"); + zipOut.putNextEntry(entry); + zipOut.write(bytes); + zipOut.closeEntry(); + } + + // Return the File object representing the zip file + return tempFile; + } + + @Test + public void testUploadExtraTestFiles() throws IOException { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + project.getId() + "/tests/extrafiles"; + /* All checks succeed */ + when(testUtil.getTestIfAdmin(project.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", test)); + + Path savePath = Path.of("savePath"); + File file = createTestFile(); + + try (MockedStatic mockedFilehandler = mockStatic(Filehandler.class)) { + mockedFilehandler.when(() -> Filehandler.getTestExtraFilesPath(project.getId())).thenReturn(savePath); + mockedFilehandler.when(() -> Filehandler.saveFile(savePath, mockMultipartFile, Filehandler.EXTRA_TESTFILES_FILENAME)) + .thenReturn(file); + + when(fileRepository.save(argThat( + fileEntity -> fileEntity.getName().equals(mockMultipartFile.getOriginalFilename()) && + fileEntity.getPath() + .equals(savePath.resolve(Filehandler.EXTRA_TESTFILES_FILENAME).toString()) && + fileEntity.getUploadedBy() == getMockUser().getId() + ))).thenReturn(fileEntity); + + when(testRepository.save(test)).thenReturn(test); + when(entityToJsonConverter.testEntityToTestJson(test, project.getId())).thenReturn(testJson); + + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile) + .with(request -> { + request.setMethod("PUT"); + return request; + }) + ).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + verify(testRepository, times(1)).save(test); + assertEquals(fileEntity.getId(), test.getExtraFilesId()); + + /* Unexpected error */ + mockedFilehandler.when(() -> Filehandler.saveFile(savePath, mockMultipartFile, Filehandler.EXTRA_TESTFILES_FILENAME)) + .thenThrow(new IOException("Unexpected error")); + + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile) + .with(request -> { + request.setMethod("PUT"); + return request; + }) + ).andExpect(status().isInternalServerError()); + + /* Check fails */ + when(testUtil.getTestIfAdmin(project.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); + + mockMvc.perform(MockMvcRequestBuilders.multipart(url) + .file(mockMultipartFile) + .with(request -> { + request.setMethod("PUT"); + return request; + }) + ).andExpect(status().isIAmATeapot()); + + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void testDeleteExtraFiles() throws Exception { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + project.getId() + "/tests/extrafiles"; + test.setExtraFilesId(fileEntity.getId()); + + /* All checks succeed */ + when(testUtil.getTestIfAdmin(project.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", test)); + + when(fileRepository.findById(test.getExtraFilesId())).thenReturn(Optional.of(fileEntity)); + when(testRepository.save(test)).thenReturn(test); + when(entityToJsonConverter.testEntityToTestJson(test, project.getId())).thenReturn(testJson); + + when(fileUtil.deleteFileById(test.getExtraFilesId())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(objectMapper.writeValueAsString(testJson))); + + verify(testRepository, times(1)).save(test); + verify(fileUtil, times(1)).deleteFileById(fileEntity.getId()); + assertNull(test.getExtraFilesId()); + + /* Unexpected error when deleting file */ + test.setExtraFilesId(fileEntity.getId()); + when(fileUtil.deleteFileById(test.getExtraFilesId())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "Unexpected error", null)); + + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isIAmATeapot()); + + /* Error thrown */ + test.setExtraFilesId(fileEntity.getId()); + when(fileUtil.deleteFileById(test.getExtraFilesId())) + .thenThrow(new RuntimeException("Error thrown")); + + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isInternalServerError()); + + /* No extra files */ + test.setExtraFilesId(null); + + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isNotFound()); + + /* Check fails */ + when(testUtil.getTestIfAdmin(project.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); + + mockMvc.perform(MockMvcRequestBuilders.delete(url)) + .andExpect(status().isIAmATeapot()); + } + + @Test + public void getExtraTestFiles() { + String url = ApiRoutes.PROJECT_BASE_PATH + "/" + project.getId() + "/tests/extrafiles"; + + ResponseEntity mockResponseEntity = ResponseEntity.ok().build(); + test.setExtraFilesId(fileEntity.getId()); + + /* All checks succeed */ + when(testUtil.getTestIfAdmin(project.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", test)); + + when(fileRepository.findById(test.getExtraFilesId())).thenReturn(Optional.of(fileEntity)); + + try (MockedStatic mockedFilehandler = mockStatic(Filehandler.class)) { + mockedFilehandler.when(() -> Filehandler.getZipFileAsResponse(argThat( + path -> path.toString().equals(fileEntity.getPath()) + ), eq(fileEntity.getName()))) + .thenReturn(mockResponseEntity); + + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isOk()); + + /* Files not found */ + when(fileRepository.findById(test.getExtraFilesId())).thenReturn(Optional.empty()); + + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isNotFound()); + + /* No extra files */ + test.setExtraFilesId(null); + + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isNotFound()); + + /* check fails */ + when(testUtil.getTestIfAdmin(project.getId(), getMockUser())) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "I'm a teapot", null)); + + mockMvc.perform(MockMvcRequestBuilders.get(url)) + .andExpect(status().isIAmATeapot()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/controllers/UserControllerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/controllers/UserControllerTest.java index a60a6cf2..53d3c904 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/controllers/UserControllerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/controllers/UserControllerTest.java @@ -49,7 +49,7 @@ public class UserControllerTest extends ControllerTest { @BeforeEach public void setup() { setUpController(userController); - userEntity = new UserEntity("Bob", "Testman", "email", UserRole.student, "azureId"); + userEntity = new UserEntity("Bob", "Testman", "email", UserRole.student, "azureId", ""); userEntity.setId(74L); mockUserJson = new UserJson(getMockUser()); userJson = new UserJson(userEntity); @@ -260,7 +260,7 @@ public void testUpdateUserById() throws Exception { setMockUserRoles(UserRole.admin); String url = ApiRoutes.USERS_BASE_PATH + "/" + userEntity.getId(); String request = "{\"name\":\"John\",\"surname\":\"Doe\",\"email\":\"john@example.com\",\"role\":\"teacher\"}"; - UserEntity updateUserEntity = new UserEntity("John", "Doe", "john@example.com", UserRole.teacher, "azureId"); + UserEntity updateUserEntity = new UserEntity("John", "Doe", "john@example.com", UserRole.teacher, "azureId", ""); updateUserEntity.setId(userEntity.getId()); UserJson updatedUserJson = new UserJson(updateUserEntity); @@ -311,7 +311,7 @@ public void testPatchUserById() throws Exception { setMockUserRoles(UserRole.admin); String url = ApiRoutes.USERS_BASE_PATH + "/" + userEntity.getId(); String request = "{\"name\":\"John\",\"surname\":\"Doe\",\"email\":\"john@example.com\",\"role\":\"teacher\"}"; - UserEntity updateUserEntity = new UserEntity("John", "Doe", "john@example.com", UserRole.teacher, "azureId"); + UserEntity updateUserEntity = new UserEntity("John", "Doe", "john@example.com", UserRole.teacher, "azureId", ""); updateUserEntity.setId(userEntity.getId()); UserJson updatedUserJson = new UserJson(updateUserEntity); String originalName = userEntity.getName(); diff --git a/backend/app/src/test/java/com/ugent/pidgeon/docker/DockerSubmissionTestTest.java b/backend/app/src/test/java/com/ugent/pidgeon/docker/DockerSubmissionTestTest.java index e0507156..83b08576 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/docker/DockerSubmissionTestTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/docker/DockerSubmissionTestTest.java @@ -1,7 +1,9 @@ package com.ugent.pidgeon.docker; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; @@ -11,6 +13,7 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.nio.file.Path; import java.util.List; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -195,18 +198,21 @@ void zipFileInputTest() throws IOException { } @Test void dockerImageDoesNotExist(){ - assertFalse(DockerSubmissionTestModel.imageExists("BADUBADUBADUBADUBADUBADUB")); + assertFalse(DockerSubmissionTestModel.imageExists("BADUBADUBADUBADUBADUBADUB - miauw :3")); + assertFalse(DockerSubmissionTestModel.imageExists("alpine:v69696969")); assertTrue(DockerSubmissionTestModel.imageExists("alpine:latest")); } @Test - void isValidTemplate(){ - assertFalse(DockerSubmissionTestModel.isValidTemplate("This is not a valid template")); - assertTrue(DockerSubmissionTestModel.isValidTemplate("@HelloWorld\n" + + void tryTemplate(){ + assertThrows(IllegalArgumentException.class,() -> DockerSubmissionTestModel.tryTemplate("This is not a valid template")); + + + assertDoesNotThrow(() -> DockerSubmissionTestModel.tryTemplate("@HelloWorld\n" + ">Description=\"Test for hello world!\"\n" + ">Required\n" + "HelloWorld!")); - assertTrue(DockerSubmissionTestModel.isValidTemplate("@helloworld\n" + assertDoesNotThrow(() -> DockerSubmissionTestModel.tryTemplate("@helloworld\n" + ">required\n" + ">description=\"Helloworldtest\"\n" + "Hello World\n" @@ -215,4 +221,23 @@ void isValidTemplate(){ + "bruh\n")); } + @Test + void testDockerReceivesUtilFiles(){ + DockerSubmissionTestModel stm = new DockerSubmissionTestModel("alpine:latest"); + Path zipLocation = Path.of("src/test/test-cases/DockerSubmissionTestTest/d__test.zip"); // simple zip with one file + Path zipLocation2 = Path.of("src/test/test-cases/DockerSubmissionTestTest/helloworld.zip"); // complicated zip with multiple files and folder structure + stm.addUtilFiles(zipLocation); + stm.addUtilFiles(zipLocation2); + DockerTestOutput to = stm.runSubmission("find /shared/extra/"); + List logs = to.logs.stream().map(log -> log.replaceAll("\n", "")).sorted().toList(); + assertEquals("/shared/extra/", logs.get(0)); + assertEquals("/shared/extra/helloworld", logs.get(1)); + assertEquals("/shared/extra/helloworld.txt", logs.get(2)); + assertEquals("/shared/extra/helloworld/emptyfolder", logs.get(3)); + assertEquals("/shared/extra/helloworld/helloworld1.txt", logs.get(4)); + assertEquals("/shared/extra/helloworld/helloworld2.txt", logs.get(5)); // I don't understand the order of find :sob: but it is important all files are found. + assertEquals("/shared/extra/helloworld/helloworld3.txt", logs.get(6)); + stm.cleanUp(); + } + } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/global/RolesInterceptorTest.java b/backend/app/src/test/java/com/ugent/pidgeon/global/RolesInterceptorTest.java index bff3e4e1..76b25d2e 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/global/RolesInterceptorTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/global/RolesInterceptorTest.java @@ -76,6 +76,7 @@ void testUserDoesntExistYet() throws Exception { user.getName().equals(getMockUser().getName()) && user.getSurname().equals(getMockUser().getSurname()) && user.getEmail().equals(getMockUser().getEmail()) && + user.getStudentNumber().equals(getMockUser().getStudentNumber()) && duration.getSeconds() < 5; } ))).thenReturn(getMockUser()); diff --git a/backend/app/src/test/java/com/ugent/pidgeon/model/AuthTest.java b/backend/app/src/test/java/com/ugent/pidgeon/model/AuthTest.java index 6f415d42..738f922f 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/model/AuthTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/model/AuthTest.java @@ -10,7 +10,7 @@ public class AuthTest { - private final User testUser = new User("John Doe", "John", "Doe", "john.doe@gmail.com", "123456"); + private final User testUser = new User("John Doe", "John", "Doe", "john.doe@gmail.com", "123456", ""); private final List authLijst = List.of(new SimpleGrantedAuthority("READ_AUTHORITY")); private final Auth auth = new Auth(testUser, authLijst); diff --git a/backend/app/src/test/java/com/ugent/pidgeon/model/FileStructureTest.java b/backend/app/src/test/java/com/ugent/pidgeon/model/FileStructureTest.java index 3d7a74c3..63889c90 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/model/FileStructureTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/model/FileStructureTest.java @@ -7,7 +7,9 @@ import java.nio.file.Files; import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class FileStructureTest { @@ -43,6 +45,17 @@ void isEmpty(){ void denyFileExtension(){ assertFalse(runTest("noClassExtensions")); } + + @Test + void tryTemplateTest(){ + assertDoesNotThrow(() -> SubmissionTemplateModel.tryTemplate("template")); + assertDoesNotThrow(() -> SubmissionTemplateModel.tryTemplate("src/\n index.js\n seconfilehehe.file")); + assertThrows(IllegalArgumentException.class, () -> SubmissionTemplateModel.tryTemplate("src/\n index.js\n seconfilehehe.file\n thirdfile")); + //check trailing newline + assertDoesNotThrow(() -> SubmissionTemplateModel.tryTemplate("src/\n\tindex.js\n")); + + } + private boolean runTest(String testpath){ SubmissionTemplateModel model = new SubmissionTemplateModel(); if(testpath.lastIndexOf('/') != testpath.length() - 1){ diff --git a/backend/app/src/test/java/com/ugent/pidgeon/model/UserTest.java b/backend/app/src/test/java/com/ugent/pidgeon/model/UserTest.java index 38e15043..e410fca3 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/model/UserTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/model/UserTest.java @@ -5,7 +5,7 @@ public class UserTest { - private final User testUser = new User("John Doe", "John", "Doe", "john.doe@gmail.com", "123456"); + private final User testUser = new User("John Doe", "John", "Doe", "john.doe@gmail.com", "123456", ""); @Test public void isNotNull() { diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/ClusterUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/ClusterUtilTest.java index 37588085..9a1e2609 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/ClusterUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/ClusterUtilTest.java @@ -54,7 +54,7 @@ public class ClusterUtilTest { public void setUp() { clusterEntity = new GroupClusterEntity(1L, 20, "clustername", 5); clusterEntity.setId(4L); - mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); + mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid", ""); } @Test @@ -226,38 +226,38 @@ void testCheckGroupClusterUpdateJson() { @Test void testCheckGroupClusterCreateJson() { - GroupClusterCreateJson json = new GroupClusterCreateJson("clustername", 5, 5); + GroupClusterCreateJson json = new GroupClusterCreateJson("clustername", 5, 5, null); /* All checks succeed */ CheckResult result = clusterUtil.checkGroupClusterCreateJson(json); assertEquals(HttpStatus.OK, result.getStatus()); /* GroupCount is negative */ - json = new GroupClusterCreateJson("clustername", 5, -5); + json = new GroupClusterCreateJson("clustername", 5, -5, null); result = clusterUtil.checkGroupClusterCreateJson(json); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); /* Capacity is smaller than 1 */ - json = new GroupClusterCreateJson("clustername", 0, 5); + json = new GroupClusterCreateJson("clustername", 0, 5, null); result = clusterUtil.checkGroupClusterCreateJson(json); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); /* Name is empty */ - json = new GroupClusterCreateJson("", 5, 5); + json = new GroupClusterCreateJson("", 5, 5, null); result = clusterUtil.checkGroupClusterCreateJson(json); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); /* Capacity is null */ - json = new GroupClusterCreateJson("clustername", null, 5); + json = new GroupClusterCreateJson("clustername", null, 5, null); result = clusterUtil.checkGroupClusterCreateJson(json); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); /* Name is null */ - json = new GroupClusterCreateJson(null, 5, 5); + json = new GroupClusterCreateJson(null, 5, 5, null); result = clusterUtil.checkGroupClusterCreateJson(json); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); /* GroupCount is null */ - json = new GroupClusterCreateJson("clustername", 5, null); + json = new GroupClusterCreateJson("clustername", 5, null, null); result = clusterUtil.checkGroupClusterCreateJson(json); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/CommonDataBaseActionsTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/CommonDataBaseActionsTest.java index 8fedae77..f0cabe86 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/CommonDataBaseActionsTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/CommonDataBaseActionsTest.java @@ -144,7 +144,7 @@ public void setUp() { submissionEntity = new SubmissionEntity( 22, - 45, + 45L, 99L, OffsetDateTime.MIN, true, diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/CourseUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/CourseUtilTest.java index 089851cb..f5593dad 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/CourseUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/CourseUtilTest.java @@ -56,7 +56,7 @@ public class CourseUtilTest { @BeforeEach public void setUp() { - user = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); + user = new UserEntity("name", "surname", "email", UserRole.student, "azureid", ""); user.setId(44L); course = new CourseEntity("name", "description",2024); course.setId(9L); diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/EntityToJsonConverterTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/EntityToJsonConverterTest.java index fa1a2daa..2fcc809b 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/EntityToJsonConverterTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/EntityToJsonConverterTest.java @@ -24,6 +24,8 @@ import com.ugent.pidgeon.postgre.models.types.UserRole; import com.ugent.pidgeon.postgre.repository.*; import com.ugent.pidgeon.postgre.repository.GroupRepository.UserReference; +import java.io.File; +import java.io.IOException; import java.time.OffsetDateTime; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -31,6 +33,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; @@ -47,6 +50,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -76,6 +80,9 @@ public class EntityToJsonConverterTest { @Mock private SubmissionRepository submissionRepository; + @Mock + private FileRepository fileRepository; + @Spy @InjectMocks private EntityToJsonConverter entityToJsonConverter; @@ -131,13 +138,15 @@ public void setUp() { "surname", "email", UserRole.student, - "azureId" + "azureId", + "" ); userEntity.setId(44L); userReferenceJson = new UserReferenceJson( userEntity.getName() + " " + userEntity.getSurname(), userEntity.getEmail(), - userEntity.getId() + userEntity.getId(), + "" ); otherUser = new UserEntity( @@ -145,12 +154,14 @@ public void setUp() { "otherSurname", "otherEmail", UserRole.student, - "otherAzureId" + "otherAzureId", + "" ); otherUserReferenceJson = new UserReferenceJson( otherUser.getName() + " " + otherUser.getSurname(), otherUser.getEmail(), - otherUser.getId() + otherUser.getId(), + "" ); @@ -190,7 +201,8 @@ public void setUp() { projectEntity.isVisible(), new ProjectProgressJson(44, 60), groupEntity.getId(), - groupClusterEntity.getId() + groupClusterEntity.getId(), + OffsetDateTime.now() ); groupFeedbackEntity = new GroupFeedbackEntity( @@ -209,7 +221,7 @@ public void setUp() { submissionEntity = new SubmissionEntity( 22, - 45, + 45L, 99L, OffsetDateTime.MIN, true, @@ -219,6 +231,7 @@ public void setUp() { @Test public void testGroupEntityToJson() { + userEntity.setStudentNumber("studentNumber"); when(groupClusterRepository.findById(groupEntity.getClusterId())).thenReturn(Optional.of(groupClusterEntity)); when(groupRepository.findGroupUsersReferencesByGroupId(anyLong())).thenReturn( List.of(new UserReference[]{ @@ -237,11 +250,16 @@ public String getName() { public String getEmail() { return userEntity.getEmail(); } + + @Override + public String getStudentNumber() { + return userEntity.getStudentNumber(); + } } }) ); - GroupJson result = entityToJsonConverter.groupEntityToJson(groupEntity); + GroupJson result = entityToJsonConverter.groupEntityToJson(groupEntity, false); assertEquals(groupClusterEntity.getMaxSize(), result.getCapacity()); assertEquals(groupEntity.getId(), result.getGroupId()); assertEquals(groupEntity.getName(), result.getName()); @@ -251,25 +269,33 @@ public String getEmail() { assertEquals(userEntity.getId(), userReferenceJson.getUserId()); assertEquals(userEntity.getName() + " " + userEntity.getSurname(), userReferenceJson.getName()); assertEquals(userEntity.getEmail(), userReferenceJson.getEmail()); + assertEquals(userEntity.getStudentNumber(), userReferenceJson.getStudentNumber()); /* Cluster is individual */ groupClusterEntity.setMaxSize(1); - result = entityToJsonConverter.groupEntityToJson(groupEntity); + result = entityToJsonConverter.groupEntityToJson(groupEntity, false); assertEquals(1, result.getCapacity()); assertNull(result.getGroupClusterUrl()); + /* StudentNumber gets hidden correctly */ + result = entityToJsonConverter.groupEntityToJson(groupEntity, true); + assertNull(result.getMembers().get(0).getStudentNumber()); + /* Issue when groupClusterEntity is null */ when(groupClusterRepository.findById(groupEntity.getClusterId())).thenReturn(Optional.empty()); - assertThrows(RuntimeException.class, () -> entityToJsonConverter.groupEntityToJson(groupEntity)); + assertThrows(RuntimeException.class, () -> entityToJsonConverter.groupEntityToJson(groupEntity, false)); } @Test public void testClusterEntityToClusterJson() { + groupClusterEntity.setLockGroupsAfter(OffsetDateTime.now()); when(groupRepository.findAllByClusterId(groupClusterEntity.getId())).thenReturn(List.of(groupEntity)); - doReturn(groupJson).when(entityToJsonConverter).groupEntityToJson(groupEntity); + doReturn(groupJson).when(entityToJsonConverter).groupEntityToJson(groupEntity, false); + + GroupClusterJson result = entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity, false); - GroupClusterJson result = entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity); + verify(entityToJsonConverter, times(1)).groupEntityToJson(groupEntity, false); assertEquals(groupClusterEntity.getId(), result.clusterId()); assertEquals(groupClusterEntity.getName(), result.name()); @@ -278,28 +304,51 @@ public void testClusterEntityToClusterJson() { assertEquals(groupClusterEntity.getCreatedAt(), result.createdAt()); assertEquals(1, result.groups().size()); assertEquals(groupJson, result.groups().get(0)); + assertEquals(groupClusterEntity.getLockGroupsAfter(), result.lockGroupsAfter()); assertEquals(ApiRoutes.COURSE_BASE_PATH + "/" + courseEntity.getId(), result.courseUrl()); + + /* Hide studentNumber */ + doReturn(groupJson).when(entityToJsonConverter).groupEntityToJson(groupEntity, true); + + result = entityToJsonConverter.clusterEntityToClusterJson(groupClusterEntity, true); + + verify(entityToJsonConverter, times(1)).groupEntityToJson(groupEntity, true); } @Test public void testUserEntityToUserReference() { - UserReferenceJson result = entityToJsonConverter.userEntityToUserReference(userEntity); + userEntity.setStudentNumber("studentNumber"); + UserReferenceJson result = entityToJsonConverter.userEntityToUserReference(userEntity, false); assertEquals(userEntity.getId(), result.getUserId()); assertEquals(userEntity.getName() + " " + userEntity.getSurname(), result.getName()); assertEquals(userEntity.getEmail(), result.getEmail()); + assertEquals(userEntity.getStudentNumber(), result.getStudentNumber()); + + /* Hide studentnumber */ + result = entityToJsonConverter.userEntityToUserReference(userEntity, true); + assertNull(result.getStudentNumber()); } @Test public void testUserEntityToUserReferenceWithRelation() { - doReturn(userReferenceJson).when(entityToJsonConverter).userEntityToUserReference(userEntity); - UserReferenceWithRelation result = entityToJsonConverter.userEntityToUserReferenceWithRelation(userEntity, CourseRelation.creator); + + doReturn(userReferenceJson).when(entityToJsonConverter).userEntityToUserReference(userEntity, false); + UserReferenceWithRelation result = entityToJsonConverter.userEntityToUserReferenceWithRelation(userEntity, CourseRelation.creator, false); assertEquals(userReferenceJson, result.getUser()); assertEquals(CourseRelation.creator.toString(), result.getRelation()); - result = entityToJsonConverter.userEntityToUserReferenceWithRelation(userEntity, CourseRelation.course_admin); + verify(entityToJsonConverter, times(1)).userEntityToUserReference(userEntity, false); + + /* Hide studentnumber */ + doReturn(userReferenceJson).when(entityToJsonConverter).userEntityToUserReference(userEntity, true); + result = entityToJsonConverter.userEntityToUserReferenceWithRelation(userEntity, CourseRelation.creator, true); + verify(entityToJsonConverter, times(1)).userEntityToUserReference(userEntity, true); + + /* Different relations */ + result = entityToJsonConverter.userEntityToUserReferenceWithRelation(userEntity, CourseRelation.course_admin, false); assertEquals(CourseRelation.course_admin.toString(), result.getRelation()); - result = entityToJsonConverter.userEntityToUserReferenceWithRelation(userEntity, CourseRelation.enrolled); + result = entityToJsonConverter.userEntityToUserReferenceWithRelation(userEntity, CourseRelation.enrolled, false); assertEquals(CourseRelation.enrolled.toString(), result.getRelation()); } @@ -312,8 +361,8 @@ public void testCourseEntityToCourseWithInfo() { when(courseRepository.findTeacherByCourseId(courseEntity.getId())).thenReturn(userEntity); when(courseRepository.findAssistantsByCourseId(courseEntity.getId())).thenReturn(List.of(otherUser)); - doReturn(userReferenceJson).when(entityToJsonConverter).userEntityToUserReference(userEntity); - doReturn(otherUserReferenceJson).when(entityToJsonConverter).userEntityToUserReference(otherUser); + doReturn(userReferenceJson).when(entityToJsonConverter).userEntityToUserReference(userEntity, true); + doReturn(otherUserReferenceJson).when(entityToJsonConverter).userEntityToUserReference(otherUser, true); CourseWithInfoJson result = entityToJsonConverter.courseEntityToCourseWithInfo(courseEntity, joinLink, false); assertEquals(courseEntity.getId(), result.courseId()); @@ -414,9 +463,9 @@ public void testProjectEntityToProjectResponseJsonWithStatus() { @Test public void testProjectEntityToProjectResponseJson() { GroupEntity secondGroup = new GroupEntity("secondGroup", groupClusterEntity.getId()); - SubmissionEntity secondSubmission = new SubmissionEntity(22, 232, 90L, OffsetDateTime.MIN, true, true); + SubmissionEntity secondSubmission = new SubmissionEntity(22, 232L, 90L, OffsetDateTime.MIN, true, true); CourseUserEntity courseUser = new CourseUserEntity(projectEntity.getCourseId(), userEntity.getId(), CourseRelation.creator); - + projectEntity.setVisibleAfter(OffsetDateTime.now()); when(projectRepository.findGroupIdsByProjectId(projectEntity.getId())).thenReturn(List.of(groupEntity.getId(), secondGroup.getId())); when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(projectEntity.getId(), groupEntity.getId())).thenReturn(Optional.of(submissionEntity)); when(submissionRepository.findLatestsSubmissionIdsByProjectAndGroupId(projectEntity.getId(), secondGroup.getId())).thenReturn(Optional.of(secondSubmission)); @@ -443,6 +492,8 @@ public void testProjectEntityToProjectResponseJson() { assertEquals(2, result.progress().total()); assertNull(result.groupId()); // User is a creator/course_admin -> no group assertEquals(groupClusterEntity.getId(), result.clusterId()); + assertEquals(projectEntity.getVisibleAfter(), result.visibleAfter()); + /* TestId is null */ projectEntity.setTestId(null); @@ -503,67 +554,102 @@ public void testCourseEntityToCourseReference() { @Test public void testGetSubmissionJson() { - submissionEntity.setDockerTestState(DockerTestState.running); - submissionEntity.setSubmissionTime(OffsetDateTime.now()); - submissionEntity.setStructureAccepted(true); - submissionEntity.setStructureFeedback("feedback"); - SubmissionJson result = entityToJsonConverter.getSubmissionJson(submissionEntity); - assertEquals(submissionEntity.getId(), result.getSubmissionId()); - assertEquals(ApiRoutes.PROJECT_BASE_PATH + "/" + submissionEntity.getProjectId(), result.getProjectUrl()); - assertEquals(ApiRoutes.GROUP_BASE_PATH + "/" + submissionEntity.getGroupId(), result.getGroupUrl()); - assertEquals(submissionEntity.getProjectId(), result.getProjectId()); - assertEquals(submissionEntity.getGroupId(), result.getGroupId()); - assertEquals(ApiRoutes.SUBMISSION_BASE_PATH + "/" + submissionEntity.getId() + "/file", result.getFileUrl()); - assertTrue(result.getStructureAccepted()); - assertEquals(submissionEntity.getSubmissionTime(), result.getSubmissionTime()); - assertEquals(submissionEntity.getStructureFeedback(), result.getStructureFeedback()); - assertNull(result.getDockerFeedback()); - assertEquals(DockerTestState.running.toString(), result.getDockerStatus()); - assertEquals(ApiRoutes.SUBMISSION_BASE_PATH + "/" + submissionEntity.getId() + "/artifacts", result.getArtifactUrl()); - - /* Docker finished running */ - submissionEntity.setDockerTestState(DockerTestState.finished); - /* No docker test */ - submissionEntity.setDockerType(DockerTestType.NONE); - result = entityToJsonConverter.getSubmissionJson(submissionEntity); - assertEquals(DockerTestState.finished.toString(), result.getDockerStatus()); - assertEquals(DockerTestType.NONE, result.getDockerFeedback().type()); - - /* Simple docker test */ - submissionEntity.setDockerFeedback("dockerFeedback - simple"); - submissionEntity.setDockerAccepted(true); - submissionEntity.setDockerType(DockerTestType.SIMPLE); - result = entityToJsonConverter.getSubmissionJson(submissionEntity); - assertEquals(DockerTestType.SIMPLE, result.getDockerFeedback().type()); - assertEquals(submissionEntity.getDockerFeedback(), result.getDockerFeedback().feedback()); - assertTrue(result.getDockerFeedback().allowed()); - - /* Template docker test */ - submissionEntity.setDockerFeedback("dockerFeedback - template"); - submissionEntity.setDockerAccepted(false); - submissionEntity.setDockerType(DockerTestType.TEMPLATE); - result = entityToJsonConverter.getSubmissionJson(submissionEntity); - assertEquals(DockerTestType.TEMPLATE, result.getDockerFeedback().type()); - assertEquals(submissionEntity.getDockerFeedback(), result.getDockerFeedback().feedback()); - assertFalse(result.getDockerFeedback().allowed()); - - /* Docker aborted */ - submissionEntity.setDockerTestState(DockerTestState.aborted); - result = entityToJsonConverter.getSubmissionJson(submissionEntity); - assertEquals(DockerTestState.aborted.toString(), result.getDockerStatus()); - assertEquals(DockerTestType.TEMPLATE, result.getDockerFeedback().type()); - assertEquals(submissionEntity.getDockerFeedback(), result.getDockerFeedback().feedback()); - assertFalse(result.getDockerFeedback().allowed()); + try (MockedStatic mockedFileHandler = mockStatic(Filehandler.class)) { + /* Create temp file for artifacts */ + File file = File.createTempFile("SELAB2CANDELETEtest", "zip"); + mockedFileHandler.when(() -> Filehandler.getSubmissionArtifactPath(submissionEntity.getProjectId(), submissionEntity.getGroupId(), submissionEntity.getId())) + .thenReturn(file.toPath()); + submissionEntity.setDockerTestState(DockerTestState.running); + submissionEntity.setSubmissionTime(OffsetDateTime.now()); + submissionEntity.setStructureAccepted(true); + submissionEntity.setStructureFeedback("feedback"); + SubmissionJson result = entityToJsonConverter.getSubmissionJson(submissionEntity); + assertEquals(submissionEntity.getId(), result.getSubmissionId()); + assertEquals(ApiRoutes.PROJECT_BASE_PATH + "/" + submissionEntity.getProjectId(), + result.getProjectUrl()); + assertEquals(ApiRoutes.GROUP_BASE_PATH + "/" + submissionEntity.getGroupId(), + result.getGroupUrl()); + assertEquals(submissionEntity.getProjectId(), result.getProjectId()); + assertEquals(submissionEntity.getGroupId(), result.getGroupId()); + assertEquals(ApiRoutes.SUBMISSION_BASE_PATH + "/" + submissionEntity.getId() + "/file", + result.getFileUrl()); + assertTrue(result.getStructureAccepted()); + assertEquals(submissionEntity.getSubmissionTime(), result.getSubmissionTime()); + assertEquals(submissionEntity.getStructureFeedback(), result.getStructureFeedback()); + assertNull(result.getDockerFeedback()); + assertEquals(DockerTestState.running.toString(), result.getDockerStatus()); + assertEquals(ApiRoutes.SUBMISSION_BASE_PATH + "/" + submissionEntity.getId() + "/artifacts", + result.getArtifactUrl()); + + /* No artifacts */ + file.delete(); + result = entityToJsonConverter.getSubmissionJson(submissionEntity); + assertNull(result.getArtifactUrl()); + + /* Docker finished running */ + submissionEntity.setDockerTestState(DockerTestState.finished); + /* No docker test */ + submissionEntity.setDockerType(DockerTestType.NONE); + result = entityToJsonConverter.getSubmissionJson(submissionEntity); + assertEquals(DockerTestState.finished.toString(), result.getDockerStatus()); + assertEquals(DockerTestType.NONE, result.getDockerFeedback().type()); + + /* Simple docker test */ + submissionEntity.setDockerFeedback("dockerFeedback - simple"); + submissionEntity.setDockerAccepted(true); + submissionEntity.setDockerType(DockerTestType.SIMPLE); + result = entityToJsonConverter.getSubmissionJson(submissionEntity); + assertEquals(DockerTestType.SIMPLE, result.getDockerFeedback().type()); + assertEquals(submissionEntity.getDockerFeedback(), result.getDockerFeedback().feedback()); + assertTrue(result.getDockerFeedback().allowed()); + + /* Template docker test */ + submissionEntity.setDockerFeedback("dockerFeedback - template"); + submissionEntity.setDockerAccepted(false); + submissionEntity.setDockerType(DockerTestType.TEMPLATE); + result = entityToJsonConverter.getSubmissionJson(submissionEntity); + assertEquals(DockerTestType.TEMPLATE, result.getDockerFeedback().type()); + assertEquals(submissionEntity.getDockerFeedback(), result.getDockerFeedback().feedback()); + assertFalse(result.getDockerFeedback().allowed()); + + /* Docker aborted */ + submissionEntity.setDockerTestState(DockerTestState.aborted); + result = entityToJsonConverter.getSubmissionJson(submissionEntity); + assertEquals(DockerTestState.aborted.toString(), result.getDockerStatus()); + assertEquals(DockerTestType.TEMPLATE, result.getDockerFeedback().type()); + assertEquals(submissionEntity.getDockerFeedback(), result.getDockerFeedback().feedback()); + assertFalse(result.getDockerFeedback().allowed()); + + /* Group id is null */ + submissionEntity.setGroupId(null); + result = entityToJsonConverter.getSubmissionJson(submissionEntity); + assertNull(result.getGroupUrl()); + } catch (IOException e) { + throw new RuntimeException(e); + } } @Test public void testTestEntityToTestJson() { + testEntity.setExtraFilesId(5L); + when(fileRepository.findById(testEntity.getExtraFilesId())) + .thenReturn(Optional.of(new FileEntity("nameoffiles", "path", 5L))); + TestJson result = entityToJsonConverter.testEntityToTestJson(testEntity, projectEntity.getId()); + + assertEquals(ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId(), result.getProjectUrl()); assertEquals(testEntity.getDockerImage(), result.getDockerImage()); assertEquals(testEntity.getDockerTestScript(), result.getDockerScript()); assertEquals(testEntity.getDockerTestTemplate(), result.getDockerTemplate()); assertEquals(testEntity.getStructureTemplate(), result.getStructureTest()); + assertEquals(ApiRoutes.PROJECT_BASE_PATH + "/" + projectEntity.getId() + "/tests/extrafiles", result.getExtraFilesUrl()); + assertEquals("nameoffiles", result.getExtraFilesName()); + + testEntity.setExtraFilesId(null); + result = entityToJsonConverter.testEntityToTestJson(testEntity, projectEntity.getId()); + assertNull(result.getExtraFilesUrl()); + assertNull(result.getExtraFilesName()); } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/FileHandlerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/FileHandlerTest.java index 89efd857..94b7a153 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/FileHandlerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/FileHandlerTest.java @@ -12,16 +12,22 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.logging.Logger; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,6 +35,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; @ExtendWith(MockitoExtension.class) @@ -57,7 +64,7 @@ public void cleanup() throws Exception { @BeforeEach public void setUp() throws IOException { - tempDir = Files.createTempDirectory("test"); + tempDir = Files.createTempDirectory("SELAB6CANDELETEtest"); fileContent = Files.readAllBytes(testFilePath.resolve(basicZipFileName)); file = new MockMultipartFile( basicZipFileName, fileContent @@ -65,8 +72,8 @@ public void setUp() throws IOException { } @Test - public void testSaveSubmission() throws Exception { - File savedFile = Filehandler.saveSubmission(tempDir, file); + public void testSaveFile() throws Exception { + File savedFile = Filehandler.saveFile(tempDir, file, Filehandler.SUBMISSION_FILENAME); assertTrue(savedFile.exists()); assertEquals(Filehandler.SUBMISSION_FILENAME, savedFile.getName()); @@ -76,8 +83,8 @@ public void testSaveSubmission() throws Exception { } @Test - public void testSaveSubmission_dirDoesntExist() throws Exception { - File savedFile = Filehandler.saveSubmission(tempDir.resolve("nonexistent"), file); + public void testSaveFile_dirDoesntExist() throws Exception { + File savedFile = Filehandler.saveFile(tempDir.resolve("nonexistent"), file, Filehandler.SUBMISSION_FILENAME); assertTrue(savedFile.exists()); assertEquals(Filehandler.SUBMISSION_FILENAME, savedFile.getName()); @@ -87,44 +94,44 @@ public void testSaveSubmission_dirDoesntExist() throws Exception { } @Test - public void testSaveSubmission_errorWhileCreatingDir() throws Exception { - assertThrows(IOException.class, () -> Filehandler.saveSubmission(Path.of(""), file)); + public void testSaveFile_errorWhileCreatingDir() throws Exception { + assertThrows(IOException.class, () -> Filehandler.saveFile(Path.of(""), file, Filehandler.SUBMISSION_FILENAME)); } @Test - public void testSaveSubmission_notAZipFile() { + public void testSaveFile_notAZipFile() { MockMultipartFile notAZipFile = new MockMultipartFile( "notAZipFile.txt", "This is not a zip file".getBytes() ); - assertThrows(IOException.class, () -> Filehandler.saveSubmission(tempDir, notAZipFile)); + assertThrows(IOException.class, () -> Filehandler.saveFile(tempDir, notAZipFile, Filehandler.SUBMISSION_FILENAME)); } @Test - public void testSaveSubmission_fileEmpty() { + public void testSaveFile_fileEmpty() { MockMultipartFile emptyFile = new MockMultipartFile( "emptyFile.txt", new byte[0] ); - assertThrows(IOException.class, () -> Filehandler.saveSubmission(tempDir, emptyFile)); + assertThrows(IOException.class, () -> Filehandler.saveFile(tempDir, emptyFile, Filehandler.SUBMISSION_FILENAME)); } @Test - public void testSaveSubmission_fileNull() { - assertThrows(IOException.class, () -> Filehandler.saveSubmission(tempDir, null)); + public void testSaveFile_fileNull() { + assertThrows(IOException.class, () -> Filehandler.saveFile(tempDir, null, Filehandler.SUBMISSION_FILENAME)); } @Test public void testDeleteLocation() throws Exception { - Path testDir = Files.createTempDirectory("test"); - Path tempFile = Files.createTempFile(testDir, "test", ".txt"); + Path testDir = Files.createTempDirectory("SELAB6CANDELETEtest"); + Path tempFile = Files.createTempFile(testDir, "SELAB6CANDELETEtest", ".txt"); Filehandler.deleteLocation(new File(tempFile.toString())); assertFalse(Files.exists(testDir)); } @Test public void testDeleteLocation_parentDirNotEmpty() throws Exception { - Path testDir = Files.createTempDirectory("test"); - Path tempFile = Files.createTempFile(testDir, "test", ".txt"); - Files.createTempFile(testDir, "test2", ".txt"); + Path testDir = Files.createTempDirectory("SELAB6CANDELETEtest"); + Path tempFile = Files.createTempFile(testDir, "SELAB6CANDELETEtest", ".txt"); + Files.createTempFile(testDir, "SELAB6CANDELETEtest2", ".txt"); Filehandler.deleteLocation(new File(tempFile.toString())); assertTrue(Files.exists(testDir)); } @@ -245,20 +252,39 @@ public void testDeleteLocation_parentDirIsNull() throws IOException { @Test public void testGetSubmissionPath() { - Path submissionPath = Filehandler.getSubmissionPath(1, 2, 3); + Path submissionPath = Filehandler.getSubmissionPath(1, 2L, 3); assertEquals(Path.of(Filehandler.BASEPATH, "projects", "1", "2", "3"), submissionPath); } + @Test + public void testGetSubmissionPath_groupIdIsNull() { + Path submissionPath = Filehandler.getSubmissionPath(1, null, 3); + assertEquals(Path.of(Filehandler.BASEPATH, "projects", "1", Filehandler.ADMIN_SUBMISSION_FOLDER, "3"), submissionPath); + } + @Test public void testGetSubmissionArtifactPath() { - Path submissionArtifactPath = Filehandler.getSubmissionArtifactPath(1, 2, 3); + Path submissionArtifactPath = Filehandler.getSubmissionArtifactPath(1, 2L, 3); assertEquals(Path.of(Filehandler.BASEPATH, "projects", "1", "2", "3", "artifacts.zip"), submissionArtifactPath); } + @Test + + public void testGetTextExtraFilesPath() { + Path textExtraFilesPath = Filehandler.getTestExtraFilesPath(88); + assertEquals(Path.of(Filehandler.BASEPATH, "projects", String.valueOf(88)), textExtraFilesPath); + } + @Test + public void testGetSubmissionArtifactPath_groupIdIsNull() { + Path submissionArtifactPath = Filehandler.getSubmissionArtifactPath(1, null, 3); + assertEquals(Path.of(Filehandler.BASEPATH, "projects", "1", Filehandler.ADMIN_SUBMISSION_FOLDER, "3", "artifacts.zip"), submissionArtifactPath); + + } + @Test public void testGetFileAsResource_FileExists() { try { - File tempFile = Files.createTempFile("testFile", ".txt").toFile(); + File tempFile = Files.createTempFile("SELAB6CANDELETEtestFile", ".txt").toFile(); Resource resource = Filehandler.getFileAsResource(tempFile.toPath()); @@ -282,8 +308,8 @@ public void testGetFileAsResource_FileDoesNotExist() { @Test public void testCopyFilesAsZip() throws IOException { List files = new ArrayList<>(); - File tempFile1 = Files.createTempFile("tempFile1", ".txt").toFile(); - File tempFile2 = Files.createTempFile("tempFile2", ".txt").toFile(); + File tempFile1 = Files.createTempFile("SELAB6CANDELETEtempFile1", ".txt").toFile(); + File tempFile2 = Files.createTempFile("SELAB6CANDELETEtempFile2", ".txt").toFile(); try { files.add(tempFile1); @@ -311,9 +337,9 @@ public void testCopyFilesAsZip() throws IOException { @Test public void testCopyFilesAsZip_zipFileAlreadyExist() throws IOException { List files = new ArrayList<>(); - File tempFile1 = Files.createTempFile("tempFile1", ".txt").toFile(); - File tempFile2 = Files.createTempFile("tempFile2", ".txt").toFile(); - File zipFile = Files.createTempFile(tempDir, "files", ".zip").toFile(); + File tempFile1 = Files.createTempFile("SELAB6CANDELETEtempFile1", ".txt").toFile(); + File tempFile2 = Files.createTempFile("SELAB6CANDELETEtempFile2", ".txt").toFile(); + File zipFile = Files.createTempFile(tempDir, "SELAB6CANDELETEfiles", ".zip").toFile(); try { files.add(tempFile1); @@ -353,9 +379,9 @@ private static File createTempFileWithContent(String prefix, String suffix, int @Test public void testCopyFilesAsZip_zipFileAlreadyExistNonWriteable() throws IOException { List files = new ArrayList<>(); - File tempFile1 = createTempFileWithContent("tempFile1", ".txt", 4095); - File tempFile2 = Files.createTempFile("tempFile2", ".txt").toFile(); - File zipFile = Files.createTempFile(tempDir, "files", ".zip").toFile(); + File tempFile1 = createTempFileWithContent("SELAB6CANDELETEtempFile1", ".txt", 4095); + File tempFile2 = Files.createTempFile("SELAB6CANDELETEtempFile2", ".txt").toFile(); + File zipFile = Files.createTempFile(tempDir, "SELAB6CANDELETEfiles", ".zip").toFile(); zipFile.setWritable(false); try { @@ -380,4 +406,79 @@ public void testCopyFilesAsZip_zipFileAlreadyExistNonWriteable() throws IOExcept } } + @Test + public void testGetZipFileAsResponse() throws IOException { + List files = new ArrayList<>(); + File tempFile1 = Files.createTempFile("SELAB6CANDELETEtempFile1", ".txt").toFile(); + File tempFile2 = Files.createTempFile("SELAB6CANDELETEtempFile2", ".txt").toFile(); + + try { + files.add(tempFile1); + files.add(tempFile2); + + File zipFile = tempDir.resolve("files.zip").toFile(); + Filehandler.copyFilesAsZip(files, zipFile.toPath()); + + assertTrue(zipFile.exists()); + + ResponseEntity response = Filehandler.getZipFileAsResponse(zipFile.toPath(), "customfilename.zip"); + + assertNotNull(response); + assertEquals(200, response.getStatusCodeValue()); + assertEquals("attachment; filename=customfilename.zip", response.getHeaders().get("Content-Disposition").get(0)); + assertEquals("application/zip", response.getHeaders().get("Content-Type").get(0)); + + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Test + public void testGetZipFileAsResponse_fileDoesNotExist() { + ResponseEntity response = Filehandler.getZipFileAsResponse(Path.of("nonexistent"), "customfilename.zip"); + + assertNotNull(response); + assertEquals(404, response.getStatusCodeValue()); + } + + @Test + public void testAddExistingZip() throws IOException { + // Create zip file + String zipFileName = "existingZipFile.zip"; + File tempZipFile = Files.createTempFile("SELAB6CANDELETEexistingZip", ".zip").toFile(); + + // Populate the zip file with some content + try (ZipOutputStream tempZipOutputStream = new ZipOutputStream(new FileOutputStream(tempZipFile))) { + ZipEntry entry = new ZipEntry("testFile.txt"); + tempZipOutputStream.putNextEntry(entry); + tempZipOutputStream.write("Test content".getBytes()); + tempZipOutputStream.closeEntry(); + Filehandler.addExistingZip(tempZipOutputStream, zipFileName, tempZipFile); + } + + + + + + // Check if the zip file contains the entry + try (ZipInputStream zis = new ZipInputStream(new FileInputStream(tempZipFile))) { + ZipEntry entry; + boolean found = false; + boolean originalFound = false; + while ((entry = zis.getNextEntry()) != null) { + Logger.getGlobal().info("Entry: " + entry.getName()); + if (entry.getName().equals(zipFileName)) { + found = true; + } else if (entry.getName().equals("testFile.txt")) { + originalFound = true; + } + } + assertTrue(found); + assertTrue(originalFound); + } + } + + + + } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/GroupFeedbackUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/GroupFeedbackUtilTest.java index 6eb7581c..21bb85fc 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/GroupFeedbackUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/GroupFeedbackUtilTest.java @@ -52,7 +52,7 @@ public void setup() { 10.0f, "Good job!" ); - mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); + mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid", ""); mockUser.setId(2L); projectEntity = new ProjectEntity( 13L, @@ -200,13 +200,7 @@ public void testCheckGroupFeedbackUpdateJson() { /* Score is null */ updateGroupScoreRequest.setScore(null); result = groupFeedbackUtil.checkGroupFeedbackUpdateJson(updateGroupScoreRequest, groupFeedbackEntity.getProjectId()); - assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); - - /* Score is null but so is maxScore */ - projectEntity.setMaxScore(null); - result = groupFeedbackUtil.checkGroupFeedbackUpdateJson(updateGroupScoreRequest, groupFeedbackEntity.getProjectId()); assertEquals(HttpStatus.OK, result.getStatus()); - projectEntity.setMaxScore(34); /* Feedback is null */ updateGroupScoreRequest.setScore(Float.valueOf(projectEntity.getMaxScore())); diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/GroupUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/GroupUtilTest.java index 145b1cc9..6f2789d4 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/GroupUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/GroupUtilTest.java @@ -50,7 +50,7 @@ public class GroupUtilTest { public void setup() { group = new GroupEntity("Groupname", 12L); group.setId(54L); - mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); + mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid", ""); mockUser.setId(10L); groupCluster = new GroupClusterEntity(9L, 5, "cluster test", 20); groupCluster.setId(12L); @@ -137,7 +137,7 @@ public void testCanUpdateGroup() { @Test public void TestCanAddUserToGroup() { long otherUserId = 5L; - UserEntity otherUser = new UserEntity("othername", "othersurname", "otheremail", UserRole.student, "otherazureid"); + UserEntity otherUser = new UserEntity("othername", "othersurname", "otheremail", UserRole.student, "otherazureid", ""); /* All checks succeed */ /* Trying to add yourself to the group */ when(groupRepository.findById(group.getId())).thenReturn(Optional.of(group)); @@ -155,6 +155,16 @@ public void TestCanAddUserToGroup() { CheckResult result = groupUtil.canAddUserToGroup(group.getId(), mockUser.getId(), mockUser); assertEquals(HttpStatus.OK, result.getStatus()); + /* Trying to join a group when the groups are locked */ + groupCluster.setLockGroupsAfter(OffsetDateTime.now().minusDays(1)); + result = groupUtil.canAddUserToGroup(group.getId(), mockUser.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Trying to join a group when a locktime is configured but it hasn't passed yet */ + groupCluster.setLockGroupsAfter(OffsetDateTime.now().plusDays(1)); + result = groupUtil.canAddUserToGroup(group.getId(), mockUser.getId(), mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + /* Trying to add someone else as admin */ doReturn(new CheckResult<>(HttpStatus.OK, "", null)). when(groupUtil).isAdminOfGroup(group.getId(), mockUser); @@ -166,6 +176,12 @@ public void TestCanAddUserToGroup() { result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); assertEquals(HttpStatus.OK, result.getStatus()); + /* Adding someone to a group as admin after the locktime has passed */ + groupCluster.setLockGroupsAfter(OffsetDateTime.now().minusDays(1)); + result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Group is already full but it's an admin adding someone else */ when(groupRepository.countUsersInGroup(group.getId())).thenReturn(groupCluster.getMaxSize()); result = groupUtil.canAddUserToGroup(group.getId(), otherUserId, mockUser); @@ -238,10 +254,26 @@ public void testCanRemoveUserFromGroup() throws Exception { when(groupClusterRepository.inArchivedCourse(group.getClusterId())).thenReturn(false); when(groupRepository.userInGroup(group.getId(), mockUser.getId())).thenReturn(true); when(clusterUtil.isIndividualCluster(group.getClusterId())).thenReturn(false); + when(clusterUtil.getClusterIfExists(group.getClusterId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", groupCluster)); CheckResult result = groupUtil.canRemoveUserFromGroup(group.getId(), mockUser.getId(), mockUser); assertEquals(HttpStatus.OK, result.getStatus()); + /* Trying to leave group when groups are locked */ + groupCluster.setLockGroupsAfter(OffsetDateTime.now().minusDays(1)); + result = groupUtil.canRemoveUserFromGroup(group.getId(), mockUser.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Trying to leave group when a locktime is configured but it hasn't passed yet */ + groupCluster.setLockGroupsAfter(OffsetDateTime.now().plusDays(1)); + result = groupUtil.canRemoveUserFromGroup(group.getId(), mockUser.getId(), mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* Getting cluster fails */ + when(clusterUtil.getClusterIfExists(group.getClusterId())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); + result = groupUtil.canRemoveUserFromGroup(group.getId(), mockUser.getId(), mockUser); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatus()); + /* Trying to remove someone else */ long otherUserId = 5L; doReturn(new CheckResult<>(HttpStatus.OK, "", null)). @@ -317,6 +349,18 @@ public void testCanGetProjectGroupData() throws Exception { when(projectUtil.getProjectIfExists(project.getId())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "", null)); result = groupUtil.canGetProjectGroupData(group.getId(), project.getId(), mockUser); assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); + + /* Check if groupId is null (eg: adminsubmission) */ + /* User is admin of project */ + when(projectUtil.getProjectIfExists(project.getId())).thenReturn(new CheckResult<>(HttpStatus.OK, "", project)); + when(projectUtil.isProjectAdmin(project.getId(), mockUser)).thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + result = groupUtil.canGetProjectGroupData(null, project.getId(), mockUser); + assertEquals(HttpStatus.OK, result.getStatus()); + + /* User is not admin of project */ + when(projectUtil.isProjectAdmin(project.getId(), mockUser)).thenReturn(new CheckResult<>(HttpStatus.FORBIDDEN, "", null)); + result = groupUtil.canGetProjectGroupData(null, project.getId(), mockUser); + assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/ProjectUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/ProjectUtilTest.java index 85ad1db1..347b36a3 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/ProjectUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/ProjectUtilTest.java @@ -52,7 +52,7 @@ public void setUp() { ); projectEntity.setId(64); - mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); + mockUser = new UserEntity("name", "surname", "email", UserRole.student, "azureid", ""); mockUser.setId(10L); } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java index 0875693c..a30b71a6 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/SubmissionUtilTest.java @@ -55,7 +55,7 @@ public class SubmissionUtilTest { public void setUp() { submissionEntity = new SubmissionEntity( 22, - 45, + 45L, 99L, OffsetDateTime.MIN, true, @@ -78,13 +78,14 @@ public void setUp() { "surname", "email", UserRole.student, - "azureId" + "azureId", + "" ); userEntity.setId(44L); groupEntity = new GroupEntity( "groupName", - 52L + projectEntity.getGroupClusterId() ); groupEntity.setId(4L); @@ -149,16 +150,27 @@ public void testCheckOnSubmit() { CheckResult result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); assertEquals(HttpStatus.OK, result.getStatus()); + /* User not part of group but admin */ + when(groupRepository.groupIdByProjectAndUser(projectEntity.getId(), userEntity.getId())).thenReturn(null); + when(projectUtil.isProjectAdmin(projectEntity.getId(), userEntity)) + .thenReturn(new CheckResult<>(HttpStatus.OK, "", null)); + result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.OK, result.getStatus()); + assertNull(result.getData()); + + /* User not part of group and not admin */ + when(projectUtil.isProjectAdmin(projectEntity.getId(), userEntity)) + .thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "User is not part of a group for this project", null)); + result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + + when(groupRepository.groupIdByProjectAndUser(projectEntity.getId(), userEntity.getId())).thenReturn(groupEntity.getId()); + /* Deadline passed */ projectEntity.setDeadline(OffsetDateTime.now().minusDays(1)); result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); - /* Project not found */ - when(projectUtil.getProjectIfExists(projectEntity.getId())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "Project not found", null)); - result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); - assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); - /* GroupCluster in archived course */ when(groupClusterRepository.inArchivedCourse(groupEntity.getClusterId())).thenReturn(true); result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); @@ -169,15 +181,16 @@ public void testCheckOnSubmit() { result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); - /* User not part of group */ - when(groupRepository.groupIdByProjectAndUser(projectEntity.getId(), userEntity.getId())).thenReturn(null); - result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); - assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); /* User not part of project */ when(projectUtil.userPartOfProject(projectEntity.getId(), userEntity.getId())).thenReturn(false); result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); assertEquals(HttpStatus.FORBIDDEN, result.getStatus()); + + /* Project not found */ + when(projectUtil.getProjectIfExists(projectEntity.getId())).thenReturn(new CheckResult<>(HttpStatus.I_AM_A_TEAPOT, "Project not found", null)); + result = submissionUtil.checkOnSubmit(projectEntity.getId(), userEntity); + assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/TestRunnerTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/TestRunnerTest.java index 828a0f7d..04a4a42f 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/TestRunnerTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/TestRunnerTest.java @@ -49,6 +49,7 @@ public class TestRunnerTest { private SubmissionResult submissionResult; private DockerTestOutput dockerTestOutput; private DockerTemplateTestOutput dockerTemplateTestOutput; + private final long projectId = 876L; @BeforeEach public void setUp() { @@ -95,8 +96,12 @@ public void testRunStructureTest() throws IOException { @Test public void testRunDockerTest() throws IOException { + Path outputPath = Path.of("outputPath"); + Path extraFilesPath = Path.of("extraFilesPath"); + Path extraFilesPathResolved = extraFilesPath.resolve(Filehandler.EXTRA_TESTFILES_FILENAME); + try (MockedStatic filehandler = org.mockito.Mockito.mockStatic(Filehandler.class)) { - Path outputPath = Path.of("outputPath"); + AtomicInteger filehandlerCalled = new AtomicInteger(); filehandlerCalled.set(0); filehandler.when(() -> Filehandler.copyFilesAsZip(artifacts, outputPath)).thenAnswer( @@ -104,49 +109,54 @@ public void testRunDockerTest() throws IOException { filehandlerCalled.getAndIncrement(); return null; }); + filehandler.when(() -> Filehandler.getTestExtraFilesPath(projectId)).thenReturn(extraFilesPath); when(dockerModel.runSubmissionWithTemplate(testEntity.getDockerTestScript(), testEntity.getDockerTestTemplate())) .thenReturn(dockerTemplateTestOutput); when(dockerModel.getArtifacts()).thenReturn(artifacts); - DockerOutput result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel); + DockerOutput result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel, projectId); assertEquals(dockerTemplateTestOutput, result); verify(dockerModel, times(1)).addZipInputFiles(file); verify(dockerModel, times(1)).cleanUp(); + verify(dockerModel, times(1)).addUtilFiles(extraFilesPathResolved); assertEquals(1, filehandlerCalled.get()); /* artifacts are empty */ when(dockerModel.getArtifacts()).thenReturn(Collections.emptyList()); - result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel); + result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel, projectId); assertEquals(dockerTemplateTestOutput, result); verify(dockerModel, times(2)).addZipInputFiles(file); verify(dockerModel, times(2)).cleanUp(); + verify(dockerModel, times(2)).addUtilFiles(extraFilesPathResolved); assertEquals(1, filehandlerCalled.get()); /* aritifacts are null */ when(dockerModel.getArtifacts()).thenReturn(null); - result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel); + result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel, projectId); assertEquals(dockerTemplateTestOutput, result); verify(dockerModel, times(3)).addZipInputFiles(file); verify(dockerModel, times(3)).cleanUp(); + verify(dockerModel, times(3)).addUtilFiles(extraFilesPathResolved); assertEquals(1, filehandlerCalled.get()); /* No template */ testEntity.setDockerTestTemplate(null); when(dockerModel.runSubmission(testEntity.getDockerTestScript())).thenReturn(dockerTestOutput); - result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel); + result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel, projectId); assertEquals(dockerTestOutput, result); verify(dockerModel, times(4)).addZipInputFiles(file); verify(dockerModel, times(4)).cleanUp(); + verify(dockerModel, times(4)).addUtilFiles(extraFilesPathResolved); /* Error gets thrown */ when(dockerModel.runSubmission(testEntity.getDockerTestScript())).thenThrow(new RuntimeException("Error")); - assertThrows(Exception.class, () -> new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel)); + assertThrows(Exception.class, () -> new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel, projectId)); verify(dockerModel, times(5)).cleanUp(); /* No script */ testEntity.setDockerTestScript(null); - result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel); + result = new TestRunner().runDockerTest(file, testEntity, outputPath, dockerModel, projectId); assertNull(result); } diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/TestUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/TestUtilTest.java index 3f132ec0..34359d70 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/TestUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/TestUtilTest.java @@ -1,6 +1,7 @@ package com.ugent.pidgeon.util; import com.ugent.pidgeon.model.submissionTesting.DockerSubmissionTestModel; +import com.ugent.pidgeon.model.submissionTesting.SubmissionTemplateModel; import com.ugent.pidgeon.postgre.models.ProjectEntity; import com.ugent.pidgeon.postgre.models.TestEntity; import com.ugent.pidgeon.postgre.models.UserEntity; @@ -64,7 +65,8 @@ public void setUp() { "surname", "email", UserRole.student, - "azureId" + "azureId", + "" ); userEntity.setId(44L); testEntity = new TestEntity( @@ -92,6 +94,7 @@ public void testCheckForTestUpdate() { String dockerImage = "dockerImage"; String dockerScript = "dockerScript"; String dockerTemplate = "@dockerTemplate\nExpectedOutput"; + String structureTemplate = "src/\n\tindex.js\n"; HttpMethod httpMethod = HttpMethod.POST; when(projectUtil.getProjectIfAdmin(projectEntity.getId(), userEntity)) @@ -99,9 +102,15 @@ public void testCheckForTestUpdate() { doReturn(testEntity).when(testUtil).getTestIfExists(projectEntity.getId()); - try (MockedStatic mockedTestModel = mockStatic(DockerSubmissionTestModel.class)) { + try (MockedStatic mockedTestModel = mockStatic(DockerSubmissionTestModel.class); + MockedStatic mockedTemplateModel = mockStatic(SubmissionTemplateModel.class) + ) { mockedTestModel.when(() -> DockerSubmissionTestModel.imageExists(dockerImage)).thenReturn(true); - mockedTestModel.when(() -> DockerSubmissionTestModel.isValidTemplate(any())).thenReturn(true); + mockedTestModel.when(() -> DockerSubmissionTestModel.tryTemplate(dockerTemplate)).then( + invocation -> null); + mockedTemplateModel.when(() -> SubmissionTemplateModel.tryTemplate(structureTemplate)).then( + invocation -> null); + projectEntity.setTestId(null); CheckResult> result = testUtil.checkForTestUpdate( projectEntity.getId(), @@ -109,6 +118,7 @@ public void testCheckForTestUpdate() { dockerImage, dockerScript, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.OK, result.getStatus()); @@ -123,24 +133,44 @@ public void testCheckForTestUpdate() { dockerImage, dockerScript, dockerTemplate, + null, HttpMethod.POST ); assertEquals(HttpStatus.OK, result.getStatus()); doReturn(testEntity).when(testUtil).getTestIfExists(projectEntity.getId()); - /* Not a valid template */ - when(DockerSubmissionTestModel.isValidTemplate(any())).thenReturn(false); + /* Not a valid docker template */ + mockedTestModel.when(() -> DockerSubmissionTestModel.tryTemplate(dockerTemplate)) + .thenThrow(new IllegalArgumentException("Invalid template")); + result = testUtil.checkForTestUpdate( + projectEntity.getId(), + userEntity, + dockerImage, + dockerScript, + dockerTemplate, + structureTemplate, + httpMethod + ); + assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); + mockedTestModel.when(() -> DockerSubmissionTestModel.tryTemplate(dockerTemplate)).then( + invocation -> null); + + /* Invalid structure template */ + mockedTemplateModel.when(() -> SubmissionTemplateModel.tryTemplate(structureTemplate)) + .thenThrow(new IllegalArgumentException("Invalid template")); result = testUtil.checkForTestUpdate( projectEntity.getId(), userEntity, dockerImage, dockerScript, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); - when(DockerSubmissionTestModel.isValidTemplate(any())).thenReturn(true); + mockedTemplateModel.when(() -> SubmissionTemplateModel.tryTemplate(structureTemplate)). + then(invocation -> null); /* Method is patch and no template provided */ @@ -152,6 +182,7 @@ public void testCheckForTestUpdate() { dockerImage, dockerScript, null, + structureTemplate, httpMethod ); assertEquals(HttpStatus.OK, result.getStatus()); @@ -164,6 +195,7 @@ public void testCheckForTestUpdate() { dockerImage, null, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); @@ -176,6 +208,7 @@ public void testCheckForTestUpdate() { dockerImage, null, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.OK, result.getStatus()); @@ -188,6 +221,7 @@ public void testCheckForTestUpdate() { null, dockerScript, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); @@ -200,6 +234,7 @@ public void testCheckForTestUpdate() { null, dockerScript, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.OK, result.getStatus()); @@ -213,6 +248,7 @@ public void testCheckForTestUpdate() { dockerImage, dockerScript, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.OK, result.getStatus()); @@ -227,6 +263,7 @@ public void testCheckForTestUpdate() { null, null, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); @@ -238,6 +275,7 @@ public void testCheckForTestUpdate() { null, null, null, + structureTemplate, httpMethod ); assertEquals(HttpStatus.OK, result.getStatus()); @@ -250,6 +288,7 @@ public void testCheckForTestUpdate() { dockerImage, dockerScript, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); @@ -262,6 +301,7 @@ public void testCheckForTestUpdate() { dockerImage, null, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); @@ -273,6 +313,7 @@ public void testCheckForTestUpdate() { null, dockerScript, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.BAD_REQUEST, result.getStatus()); @@ -285,6 +326,7 @@ public void testCheckForTestUpdate() { dockerImage, dockerScript, dockerTemplate, + structureTemplate, HttpMethod.POST ); assertEquals(HttpStatus.CONFLICT, result.getStatus()); @@ -297,6 +339,7 @@ public void testCheckForTestUpdate() { null, null, null, + structureTemplate, httpMethod ); assertEquals(HttpStatus.OK, result.getStatus()); @@ -309,6 +352,7 @@ public void testCheckForTestUpdate() { dockerImage, dockerScript, dockerTemplate, + structureTemplate, HttpMethod.PATCH ); assertEquals(HttpStatus.NOT_FOUND, result.getStatus()); @@ -323,6 +367,7 @@ public void testCheckForTestUpdate() { dockerImage, dockerScript, dockerTemplate, + structureTemplate, httpMethod ); assertEquals(HttpStatus.I_AM_A_TEAPOT, result.getStatus()); diff --git a/backend/app/src/test/java/com/ugent/pidgeon/util/UserUtilTest.java b/backend/app/src/test/java/com/ugent/pidgeon/util/UserUtilTest.java index bd9a059f..b9706a56 100644 --- a/backend/app/src/test/java/com/ugent/pidgeon/util/UserUtilTest.java +++ b/backend/app/src/test/java/com/ugent/pidgeon/util/UserUtilTest.java @@ -34,7 +34,7 @@ public class UserUtilTest { @BeforeEach public void setUp() { - user = new UserEntity("name", "surname", "email", UserRole.student, "azureid"); + user = new UserEntity("name", "surname", "email", UserRole.student, "azureid", ""); user.setId(87L); } diff --git a/backend/app/src/test/test-cases/DockerSubmissionTestTest/d__test.zip b/backend/app/src/test/test-cases/DockerSubmissionTestTest/d__test.zip index 4e389ca3..00b2b6bf 100644 Binary files a/backend/app/src/test/test-cases/DockerSubmissionTestTest/d__test.zip and b/backend/app/src/test/test-cases/DockerSubmissionTestTest/d__test.zip differ diff --git a/backend/app/src/test/test-cases/DockerSubmissionTestTest/helloworld.zip b/backend/app/src/test/test-cases/DockerSubmissionTestTest/helloworld.zip new file mode 100644 index 00000000..54fb8fc7 Binary files /dev/null and b/backend/app/src/test/test-cases/DockerSubmissionTestTest/helloworld.zip differ diff --git a/backend/app/src/test/test-cases/FileStructureTestCases/allowAll/template.txt b/backend/app/src/test/test-cases/FileStructureTestCases/allowAll/template.txt index 9abb766c..3f5e9c7c 100644 --- a/backend/app/src/test/test-cases/FileStructureTestCases/allowAll/template.txt +++ b/backend/app/src/test/test-cases/FileStructureTestCases/allowAll/template.txt @@ -1 +1 @@ -.* \ No newline at end of file +\.* \ No newline at end of file diff --git a/backend/app/src/test/test-cases/FileStructureTestCases/denyAllFiles/template.txt b/backend/app/src/test/test-cases/FileStructureTestCases/denyAllFiles/template.txt index a75f68df..ff348fc9 100644 --- a/backend/app/src/test/test-cases/FileStructureTestCases/denyAllFiles/template.txt +++ b/backend/app/src/test/test-cases/FileStructureTestCases/denyAllFiles/template.txt @@ -1 +1 @@ --. \ No newline at end of file +-\. \ No newline at end of file diff --git a/backend/database/populate_database.sql b/backend/database/populate_database.sql index f0e1d506..c5644482 100644 --- a/backend/database/populate_database.sql +++ b/backend/database/populate_database.sql @@ -20,46 +20,26 @@ INSERT INTO courses (course_id,course_name, description, course_year) VALUES -- Inserting into `course_users` -- Assume course_id and user_id start from 1 and match accordingly INSERT INTO course_users (course_id, user_id, course_relation) VALUES - (1, 1, 'enrolled'), - (2, 1, 'enrolled'), - (3, 2, 'creator'), - (4, 3, 'course_admin'), - (5, 4, 'enrolled'); - --- Inserting into `files` --- Assume files are uploaded by different users -INSERT INTO files (file_path, file_name, uploaded_by) VALUES - ('/path/to/file1', 'file1.txt', 1), - ('/path/to/file2', 'file2.txt', 2), - ('/path/to/file3', 'file3.txt', 3), - ('/path/to/file4', 'file4.txt', 4), - ('/path/to/file5', 'file5.txt', 1), - ('/path/to/file6', 'file6.txt', 2), - ('/path/to/file7', 'file7.txt', 3), - ('/path/to/file8', 'file8.txt', 4), - ('/path/to/file9', 'file9.txt', 1), - ('/path/to/file10', 'file10.txt', 2), - ('/path/to/file11', 'file11.txt', 3), - ('/path/to/file12', 'file12.txt', 4), - ('/path/to/file13', 'file13.txt', 5), - ('/path/to/file14', 'file14.txt', 4), - ('/path/to/file15', 'file15.txt', 5), - ('/path/to/file16', 'file16.txt', 1), - ('/path/to/file17', 'file17.txt', 2), - ('/path/to/file18', 'file18.txt', 3), - ('/path/to/file19', 'file19.txt', 4), - ('/path/to/file20', 'file20.txt', 1), - ('/path/to/file21', 'file21.txt', 2), - ('/path/to/file22', 'file22.txt', 3); + (1, 1, 'creator'), + (2, 1, 'enrolled'), + (2, 2, 'creator'), + (3, 2, 'creator'), + (4, 3, 'creator'), + (5, 4, 'creator'); -- Inserting into `group_clusters` INSERT INTO group_clusters (course_id, cluster_name, max_size, group_amount) VALUES - (1, 'Project: priemgetallen', 4, 20), - (2, 'Analyse van alkanen', 3, 10), - (3, 'Groepswerk industriële revolutie', 5, 13), - (4, 'Linux practica', 2, 100), - (5, 'Review: A shaskespeare story', 3, 30); + (1, 'Project: priemgetallen', 4, 0), + (2, 'Analyse van alkanen', 3, 0), + (3, 'Groepswerk industriële revolutie', 5, 0), + (4, 'Linux practica', 2, 0), + (5, 'Review: A shaskespeare story', 3, 0), + (1, 'Students', 1, 0), + (2, 'Students', 1, 0), + (3, 'Students', 1, 0), + (4, 'Students', 1, 0), + (5, 'Students', 1, 0); -- Inserting into `groups` INSERT INTO groups (group_name, group_cluster) VALUES @@ -67,16 +47,13 @@ INSERT INTO groups (group_name, group_cluster) VALUES ('Group 2', 2), ('Group 3', 3), ('Group 4', 4), - ('Group 5', 5); + ('Group 5', 5), + ('Naam van degene die het script heeft uitgevoerd', 7); -- Inserting into `group_users` -- Linking users to groups, assuming group_id and user_id start from 1 INSERT INTO group_users (group_id, user_id) VALUES - (1, 1), - (2, 2), - (3, 3), - (4, 4), - (5, 5); + (6, 1); @@ -133,36 +110,6 @@ def global_multiple_alignment(infile: str | Path, output: str | Path | None = No ```', 2, 6, '2024-06-22 12:00:00+02'), (3, null, 'History Essay 1', 'Discuss historical event', 3, NULL, '2024-03-22 12:00:00+02'), (4, null, 'Programming Assignment 1', 'Write code', 4, 4, '2024-03-23 14:45:00+02'), - (5, null, 'Literature Analysis', 'Analyze text', 5, 10, '2024-03-24 10:00:00+02'); - - --- Inserting into `group_grades` --- Assign grades to group solutions -INSERT INTO group_feedback(group_id, project_id, grade, feedback) VALUES - (1, 1, 95.0, ''), - (2, 2, 88.5, ''), - (3, 3, NULL, ''), - (4, 4, 89.0, ''), - (5, 5, 94.5, ''); - - -INSERT INTO submissions ( - project_id, - group_id, - file_id, - structure_accepted, - docker_accepted, - structure_feedback, - docker_feedback, - docker_test_state, - docker_type -) VALUES - (1, 1, 1, true, true, NULL, NULL, 'finished', 'simple'), - (2, 2, 2, false, true, 'ERROR: .....', NULL, 'finished', 'simple'), - (3, 3, 3, true, false, NULL, 'Docker configuration needs improvement', 'finished', 'simple'), - (4, 4, 4, false, false, 'Structure needs improvement', 'Docker configuration needs improvement', 'finished', 'simple'); - - - --- Makes user with id 1 the creator of courses -UPDATE course_users SET course_relation = 'creator' WHERE user_id = 1 + (5, null, 'Literature Analysis', 'Analyze text', 5, 10, '2024-03-24 10:00:00+02'), + (1, null, 'Individueel project', 'Beschrijving voor individueel project', 6, 20, '2024-05-22 12:00:00+02'), + (2, null, 'Individueel project', 'Beschrijving voor individueel project', 7, 20, '2024-05-22 12:00:00+02'); diff --git a/backend/database/start_database.sql b/backend/database/start_database.sql index 94cf30e6..9c15a5be 100644 --- a/backend/database/start_database.sql +++ b/backend/database/start_database.sql @@ -9,6 +9,7 @@ CREATE TABLE users ( email VARCHAR(100) UNIQUE NOT NULL, azure_id VARCHAR(255) NOT NULL, role VARCHAR(50) NOT NULL, + studentnumber VARCHAR(50), created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); @@ -37,6 +38,7 @@ CREATE TABLE group_clusters ( max_size INT NOT NULL, cluster_name VARCHAR(100) NOT NULL, group_amount INT NOT NULL, + lock_groups_after TIMESTAMP WITH TIME ZONE DEFAULT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); @@ -56,7 +58,8 @@ CREATE TABLE tests ( docker_image VARCHAR(256), docker_test_script TEXT, docker_test_template TEXT, - structure_template TEXT + structure_template TEXT, + extra_files INT REFERENCES files(file_id) ); @@ -73,7 +76,8 @@ CREATE TABLE projects ( deadline TIMESTAMP WITH TIME ZONE NOT NULL, test_id INT REFERENCES tests(test_id), visible BOOLEAN DEFAULT false NOT NULL, - max_score INT + max_score INT, + visible_after TIMESTAMP WITH TIME ZONE DEFAULT NULL ); diff --git a/backend/web-bff/App/app.js b/backend/web-bff/App/app.js index d4031e83..22211508 100644 --- a/backend/web-bff/App/app.js +++ b/backend/web-bff/App/app.js @@ -1,66 +1,130 @@ -/* - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. - */ +require('dotenv').config({path:".env"}); + +const path = require('path'); +const express = require('express'); +const session = require('express-session'); +const MongoStore = require('connect-mongo'); +const createError = require('http-errors'); +const logger = require('morgan'); -require('dotenv').config(); +const rateLimit = require('express-rate-limit') -var path = require('path'); -var express = require('express'); -var session = require('express-session'); -var createError = require('http-errors'); -var cookieParser = require('cookie-parser'); -var logger = require('morgan'); +const cors = require('cors') -var indexRouter = require('./routes/index'); -var usersRouter = require('./routes/users'); -var authRouter = require('./routes/auth'); +const indexRouter = require('./routes/index'); +const usersRouter = require('./routes/users'); +const authRouter = require('./routes/auth'); +const apiRouter = require('./routes/api'); -// initialize express -var app = express(); +/** + * Initialize express + */ +const app = express(); +const DEVELOPMENT = process.env.ENVIRONMENT === "development"; /** - * Using express-session middleware for persistent user session. Be sure to - * familiarize yourself with available options. Visit: https://www.npmjs.com/package/express-session + * Using cookie-session middleware for persistent user session. */ -app.use(session({ - secret: process.env.EXPRESS_SESSION_SECRET, - resave: false, - saveUninitialized: false, - cookie: { - httpOnly: true, - secure: false, // set this to true on production - } +const connection_string = `mongodb://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}` + + +if (DEVELOPMENT) { + // Use in memory storage for development purposes. + // Keep in mind that when the server shuts down, so does the session information. + app.use(session({ + name: 'pigeon session', + secret: process.env.EXPRESS_SESSION_SECRET, + resave: false, + saveUninitialized: false, + // expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + cookie: { + httpOnly: true, + secure: false, // make sure this is true in production + maxAge: 7 * 24 * 60 * 60 * 1000, + }, + //store: MongoStore.create( + // {mongoUrl: connection_string}) + + })); +} else { + // When using production mode, please make sure a mongodb instance is running and accepting connections + // on port PORT. Also make sure the user exists. + app.set('trust proxy', 1) + app.use(session({ + name: 'pigeon session', + secret: process.env.EXPRESS_SESSION_SECRET, + resave: false, + saveUninitialized: false, + // expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + cookie: { + httpOnly: true, + secure: true, // make sure this is true in production + maxAge: 7 * 24 * 60 * 60 * 1000, + }, + store: MongoStore.create( + {mongoUrl: connection_string}) })); +} + + +/** + * Initialize the rate limiter. + * + */ +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 4000, +}); -// view engine setup +app.use(limiter); + +/** + * Initialize the cors protection. + * Requests from our frontend are allowed. + */ +const corsOptions = { + origin: [/localhost/, "https://sel2-6.ugent.be/"], + optionsSuccessStatus: 200, + credentials: true, +} +app.use('*', cors(corsOptions)); + + +// view engine setup for debugging app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'hbs'); app.use(logger('dev')); app.use(express.json()); -app.use(cookieParser()); -app.use(express.urlencoded({ extended: false })); +app.use(express.urlencoded({extended: false})); app.use(express.static(path.join(__dirname, 'public'))); +/** + * Make our routes accessible. + */ app.use('/', indexRouter); -app.use('/users', usersRouter); -app.use('/auth', authRouter); +app.use('/web/users', usersRouter); +app.use('/web/auth', authRouter); +app.use('/web/api', apiRouter) -// catch 404 and forward to error handler +/** + * Catch 404 and forward to error handler. + */ app.use(function (req, res, next) { next(createError(404)); }); -// error handler +/** + * Error handler. + */ app.use(function (err, req, res, next) { // set locals, only providing error in development res.locals.message = err.message; - res.locals.error = req.app.get('env') === 'development' ? err : {}; + res.locals.error = DEVELOPMENT ? err : {}; // render the error page res.status(err.status || 500); res.render('error'); }); -module.exports = app; \ No newline at end of file +module.exports = app; diff --git a/backend/web-bff/App/auth/AuthProvider.js b/backend/web-bff/App/auth/AuthProvider.js index f2fb8b8f..c73d5938 100644 --- a/backend/web-bff/App/auth/AuthProvider.js +++ b/backend/web-bff/App/auth/AuthProvider.js @@ -105,13 +105,13 @@ class AuthProvider { req.session.idToken = tokenResponse.idToken; req.session.account = tokenResponse.account; - res.redirect(options.successRedirect); + next(); } catch (error) { if (error instanceof msal.InteractionRequiredAuthError) { return this.login({ scopes: options.scopes || [], redirectUri: options.redirectUri, - successRedirect: options.successRedirect || '/', + successRedirect: '/', })(req, res, next); } @@ -125,7 +125,6 @@ class AuthProvider { if (!req.body || !req.body.state) { return next(new Error('Error: response not found')); } - const authCodeRequest = { ...req.session.authCodeRequest, code: req.body.code, @@ -145,7 +144,7 @@ class AuthProvider { req.session.idToken = tokenResponse.idToken; req.session.account = tokenResponse.account; req.session.isAuthenticated = true; - + const state = JSON.parse(this.cryptoProvider.base64Decode(req.body.state)); res.redirect(state.successRedirect); } catch (error) { @@ -270,4 +269,4 @@ class AuthProvider { const authProvider = new AuthProvider(msalConfig); -module.exports = authProvider; \ No newline at end of file +module.exports = authProvider; diff --git a/backend/web-bff/App/authConfig.js b/backend/web-bff/App/authConfig.js index 26481118..80055b96 100644 --- a/backend/web-bff/App/authConfig.js +++ b/backend/web-bff/App/authConfig.js @@ -3,12 +3,10 @@ * Licensed under the MIT License. */ -require('dotenv').config({ path: '.env.dev' }); +require('dotenv').config({ path: '.env' }); /** * Configuration object to be passed to MSAL instance on creation. - * For a full list of MSAL Node configuration parameters, visit: - * https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-node/docs/configuration.md */ const msalConfig = { auth: { @@ -26,14 +24,16 @@ const msalConfig = { } } } - +/** + * Environment constants. + */ const REDIRECT_URI = process.env.REDIRECT_URI; -const POST_LOGOUT_REDIRECT_URI = process.env.POST_LOGOUT_REDIRECT_URI; +const FRONTEND_URI = process.env.FRONTEND_URI; const BACKEND_API_ENDPOINT = process.env.BACKEND_API_ENDPOINT; module.exports = { msalConfig, REDIRECT_URI, - POST_LOGOUT_REDIRECT_URI, + FRONTEND_URI, BACKEND_API_ENDPOINT -}; \ No newline at end of file +}; diff --git a/backend/web-bff/App/bin/www.js b/backend/web-bff/App/bin/www.js index 092eb15d..998c7a0d 100644 --- a/backend/web-bff/App/bin/www.js +++ b/backend/web-bff/App/bin/www.js @@ -4,22 +4,22 @@ * Module dependencies. */ -var app = require('../app'); -var debug = require('debug')('msal:server'); -var http = require('http'); +const app = require('../app'); +const debug = require('debug')('msal:server'); +const http = require('http'); + /** * Get port from environment and store in Express. */ - -var port = normalizePort(process.env.PORT || '3000'); +const port = normalizePort(process.env.PORT || '3000'); app.set('port', port); /** * Create HTTP server. */ -var server = http.createServer(app); +const server = http.createServer(app); /** * Listen on provided port, on all network interfaces. @@ -30,35 +30,30 @@ server.on('error', onError); server.on('listening', onListening); /** - * Normalize a port into a number, string, or false. + * Parse port into a number, string, or false. */ - function normalizePort(val) { - var port = parseInt(val, 10); - + let port = parseInt(val, 10); if (isNaN(port)) { // named pipe return val; } - if (port >= 0) { // port number return port; } - return false; } /** * Event listener for HTTP server "error" event. */ - function onError(error) { if (error.syscall !== 'listen') { throw error; } - var bind = typeof port === 'string' + let bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port; @@ -80,10 +75,9 @@ function onError(error) { /** * Event listener for HTTP server "listening" event. */ - function onListening() { - var addr = server.address(); - var bind = typeof addr === 'string' + let addr = server.address(); + let bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port; debug('Listening on ' + bind); diff --git a/backend/web-bff/App/fetch.js b/backend/web-bff/App/fetch.js index b3a2f6f8..5587ce1e 100644 --- a/backend/web-bff/App/fetch.js +++ b/backend/web-bff/App/fetch.js @@ -2,9 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ - -var axios = require('axios'); -const https = require('https'); +const axios = require('axios'); const {BACKEND_API_ENDPOINT} = require("./authConfig"); @@ -12,30 +10,42 @@ const {BACKEND_API_ENDPOINT} = require("./authConfig"); * Attaches a given access token to a Backend API Call * @param endpoint REST API endpoint to call * @param accessToken raw access token string + * @param method The http method for the call. Choice out of 'GET', 'PUT', etc... + * @param body body of request + * @param headers headers of request */ -async function fetch(endpoint, accessToken) { +async function fetch(endpoint, accessToken, method, body, headers) { + let methods = ["GET", "POST", "PATCH", "PUT", "DELETE"] + if (!(methods.includes(method))) { + throw new Error('Not a valid HTTP method'); + } const url = new URL(endpoint, BACKEND_API_ENDPOINT) - console.log(accessToken) - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", + const authHeaders = { + "Authorization": `Bearer ${accessToken}`, } + const finalHeaders = { ...headers, ...authHeaders } const config= { - method: "GET", + method: method, url: url.toString(), - headers: headers, + data: body, + headers: finalHeaders, } - console.log(`request made to ${BACKEND_API_ENDPOINT}/${endpoint} at: ` + new Date().toString()); + console.log(`${method} request made to ${BACKEND_API_ENDPOINT}/${endpoint} at: ` + new Date().toString()); try { - - const response = await axios(config); - return await response.data; + const res = await axios(config) + return {code: res.status, data: res.data} } catch (error) { - throw new Error(error); + if (error.response) { + return {code: error.response.status, data: error.response.data} + } else { + throw Error(error); + } } + + } -module.exports = fetch; \ No newline at end of file +module.exports = fetch; diff --git a/backend/web-bff/App/package-lock.json b/backend/web-bff/App/package-lock.json new file mode 100644 index 00000000..210260a2 --- /dev/null +++ b/backend/web-bff/App/package-lock.json @@ -0,0 +1,1955 @@ +{ + "name": "web-bff", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web-bff", + "version": "1.0.0", + "dependencies": { + "@azure/msal-node": "^2.6.4", + "axios": "^1.6.8", + "connect-mongo": "^5.1.0", + "cookie-parser": "^1.4.6", + "cookie-session": "^2.1.0", + "cors": "^2.8.5", + "csurf": "^1.11.0", + "debug": "^4.3.4", + "dotenv": "^16.4.1", + "express": "^4.19.1", + "express-rate-limit": "^7.2.0", + "express-session": "^1.18.0", + "hbs": "^4.2.0", + "helmet": "^7.1.0", + "hpp": "^0.2.3", + "http-errors": "^2.0.0", + "morgan": "^1.10.0" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.9.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.9.0.tgz", + "integrity": "sha512-yzBPRlWPnTBeixxLNI3BBIgF5/bHpbhoRVuuDBnYjCyWRavaPUsKAHUDYLqpGkBLDciA6TCc6GOxN4/S3WiSxg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.7.0.tgz", + "integrity": "sha512-wXD8LkUvHICeSWZydqg6o8Yvv+grlBEcmLGu+QEI4FcwFendbTEZrlSygnAXXSOCVaGAirWLchca35qrgpO6Jw==", + "dependencies": { + "@azure/msal-common": "14.9.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.5.tgz", + "integrity": "sha512-XLNOMH66KhJzUJNwT/qlMnS4WsNDWD5ASdyaSH3EtK+F4r/CFGa3jT4GNi4mfOitGvWXtdLgQJkQjxSVrio+jA==", + "peer": true, + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "peer": true + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.4.tgz", + "integrity": "sha512-lXCmTWSHJvf0TRSO58nm978b8HJ/EdsSsEKLd3ODHFjo+3VGAyyTp4v50nWvwtzBxSMQrVOK7tcuN0zGPLICMw==", + "peer": true, + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bson": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.6.0.tgz", + "integrity": "sha512-BVINv2SgcMjL4oYbBuCQTpE3/VKOSxrOA8Cj/wQP7izSzlBGVomdm+TcUd0Pzy0ytLSSDweCKQ6X3f5veM5LQA==", + "peer": true, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/connect-mongo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/connect-mongo/-/connect-mongo-5.1.0.tgz", + "integrity": "sha512-xT0vxQLqyqoUTxPLzlP9a/u+vir0zNkhiy9uAdHjSCcUUf7TS5b55Icw8lVyYFxfemP3Mf9gdwUOgeF3cxCAhw==", + "dependencies": { + "debug": "^4.3.1", + "kruptein": "^3.0.0" + }, + "engines": { + "node": ">=12.9.0" + }, + "peerDependencies": { + "express-session": "^1.17.1", + "mongodb": ">= 5.1.0 < 7" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-session": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.0.tgz", + "integrity": "sha512-u73BDmR8QLGcs+Lprs0cfbcAPKl2HnPcjpwRXT41sEV4DRJ2+W0vJEEZkG31ofkx+HZflA70siRIjiTdIodmOQ==", + "dependencies": { + "cookies": "0.9.1", + "debug": "3.2.7", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cookie-session/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "dependencies": { + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csurf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", + "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", + "deprecated": "Please use another csrf package", + "dependencies": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", + "http-errors": "~1.7.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/csurf/node_modules/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "node_modules/csurf/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express-rate-limit": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.2.0.tgz", + "integrity": "sha512-T7nul1t4TNyfZMJ7pKRKkdeVJWa2CqB8NA1P8BwYaoDI5QSBZARv5oMS43J7b7I5P+4asjVXjb7ONuwDKucahg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "4 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==", + "dependencies": { + "cookie": "0.6.0", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/express/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreachasync": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz", + "integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==" + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hbs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/hbs/-/hbs-4.2.0.tgz", + "integrity": "sha512-dQwHnrfWlTk5PvG9+a45GYpg0VpX47ryKF8dULVd6DtwOE6TEcYQXQ5QM6nyOx/h7v3bvEQbdn19EDAcfUAgZg==", + "dependencies": { + "handlebars": "4.7.7", + "walk": "2.3.15" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/helmet": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.1.0.tgz", + "integrity": "sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/hpp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz", + "integrity": "sha512-4zDZypjQcxK/8pfFNR7jaON7zEUpXZxz4viyFmqjb3kWNWAHsLEUmWXcdn25c5l76ISvnD6hbOGO97cXUI3Ryw==", + "dependencies": { + "lodash": "^4.17.12", + "type-is": "^1.6.12" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/kruptein": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/kruptein/-/kruptein-3.0.6.tgz", + "integrity": "sha512-EQJjTwAJfQkC4NfdQdo3HXM2a9pmBm8oidzH270cYu1MbgXPNPMJuldN7OPX+qdhPO5rw4X3/iKz0BFBfkXGKA==", + "dependencies": { + "asn1.js": "^5.4.1" + }, + "engines": { + "node": ">8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "peer": true + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mongodb": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.5.0.tgz", + "integrity": "sha512-Fozq68InT+JKABGLqctgtb8P56pRrJFkbhW0ux+x1mdHeyinor8oNzJqwLjV/t5X5nJGfTlluxfyMnOXNggIUA==", + "peer": true, + "dependencies": { + "@mongodb-js/saslprep": "^1.1.5", + "bson": "^6.4.0", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.0.tgz", + "integrity": "sha512-t1Vf+m1I5hC2M5RJx/7AtxgABy1cZmIPQRMXw+gEIPn/cZNF3Oiy+l0UIypUwVB5trcWHq3crg2g3uAR9aAwsQ==", + "peer": true, + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" + } + }, + "node_modules/morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, + "node_modules/nodemon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz", + "integrity": "sha512-xqlktYlDMCepBJd43ZQhjWwMw2obW/JRvkrLxq5RCNcuDDX1DbcPT+qT1IlIIdf+DhnWs90JpTMe+Y5KxOchvA==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rndm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", + "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "peer": true, + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "dependencies": { + "nopt": "~1.0.10" + }, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "peer": true, + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walk": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/walk/-/walk-2.3.15.tgz", + "integrity": "sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==", + "dependencies": { + "foreachasync": "^3.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "peer": true, + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } +} diff --git a/backend/web-bff/App/package.json b/backend/web-bff/App/package.json index 1deeab35..2808e361 100644 --- a/backend/web-bff/App/package.json +++ b/backend/web-bff/App/package.json @@ -2,18 +2,29 @@ "name": "web-bff", "version": "1.0.0", "scripts": { - "start": "node ./bin/www" + "start": "node ./bin/www", + "dev": "nodemon ./bin/www" }, "dependencies": { "@azure/msal-node": "^2.6.4", "axios": "^1.6.8", + "connect-mongo": "^5.1.0", "cookie-parser": "^1.4.6", + "cookie-session": "^2.1.0", + "cors": "^2.8.5", + "csurf": "^1.11.0", "debug": "^4.3.4", "dotenv": "^16.4.1", "express": "^4.19.1", + "express-rate-limit": "^7.2.0", "express-session": "^1.18.0", "hbs": "^4.2.0", + "helmet": "^7.1.0", + "hpp": "^0.2.3", "http-errors": "^2.0.0", "morgan": "^1.10.0" + }, + "devDependencies": { + "nodemon": "^3.1.0" } } diff --git a/backend/web-bff/App/routes/api.js b/backend/web-bff/App/routes/api.js new file mode 100644 index 00000000..b15647ae --- /dev/null +++ b/backend/web-bff/App/routes/api.js @@ -0,0 +1,34 @@ +const authProvider = require('../auth/AuthProvider'); + +const express = require('express'); +const router = express.Router(); + +const fetch = require('../fetch'); + +const { BACKEND_API_ENDPOINT, msalConfig, REDIRECT_URI} = require('../authConfig'); +const isAuthenticated = require('../util/isAuthenticated'); + +/** + * Route that captures every method and route starting with /web/api. + * An access token is acquired and provided in the authorization header to the backend. + * The response is sent back to the frontend. + * + * @route /web/api/* + */ +router.all('/*', + isAuthenticated("/web/auth/signin"), + authProvider.acquireToken({ + scopes: [msalConfig.auth.clientId + "/.default"], + redirectUri: REDIRECT_URI + }), + async function(req, res, next) { + + try { + const response = await fetch( "api" + req.url , req.session.accessToken, req.method, req.body, req.headers) + res.status(response.code).send(response.data) + } catch(error) { + next(error); + } + }) + +module.exports = router; \ No newline at end of file diff --git a/backend/web-bff/App/routes/auth.js b/backend/web-bff/App/routes/auth.js index de43525e..d90f8041 100644 --- a/backend/web-bff/App/routes/auth.js +++ b/backend/web-bff/App/routes/auth.js @@ -1,30 +1,32 @@ -/* - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. - */ - -var express = require('express'); +const express = require('express'); const authProvider = require('../auth/AuthProvider'); -const { REDIRECT_URI, POST_LOGOUT_REDIRECT_URI, msalConfig} = require('../authConfig'); +const { REDIRECT_URI, FRONTEND_URI, msalConfig} = require('../authConfig'); const router = express.Router(); +/** + * Route that starts the authentication flow for msal. + * + * @route GET /web/auth/singin + * + * On successful login the user is redirected to the frontend. + */ router.get('/signin', authProvider.login({ - scopes: [], - redirectUri: REDIRECT_URI, - successRedirect: '/' -})); - -router.get('/acquireToken', authProvider.acquireToken({ scopes: [msalConfig.auth.clientId + "/.default"], redirectUri: REDIRECT_URI, - successRedirect: '/users/profile' + successRedirect: FRONTEND_URI, })); + +/** + * Route that starts the logout flow for msal. + * + * @route GET /web/auth/signout + */ router.get('/signout', authProvider.logout({ - postLogoutRedirectUri: POST_LOGOUT_REDIRECT_URI + postLogoutRedirectUri: FRONTEND_URI })); module.exports = router; \ No newline at end of file diff --git a/backend/web-bff/App/routes/index.js b/backend/web-bff/App/routes/index.js index e6bd1ddb..30de7951 100644 --- a/backend/web-bff/App/routes/index.js +++ b/backend/web-bff/App/routes/index.js @@ -3,10 +3,15 @@ * Licensed under the MIT License. */ -var express = require('express'); +const express = require('express'); const authProvider = require("../auth/AuthProvider"); -var router = express.Router(); +const router = express.Router(); +/** + * Index route for debugging purposes. + * + * @route GET / + */ router.get('/', function (req, res, next) { res.render('index', { title: 'MSAL Node & Express Web App', @@ -15,6 +20,12 @@ router.get('/', function (req, res, next) { }); }); +/** + * Index route that handles a correct login in the msal library. + * This route must be /, this is configured in the application request. + * + * @route POST / + */ router.post('/', authProvider.handleRedirect()); module.exports = router; \ No newline at end of file diff --git a/backend/web-bff/App/routes/users.js b/backend/web-bff/App/routes/users.js index ce01f651..c7ae0617 100644 --- a/backend/web-bff/App/routes/users.js +++ b/backend/web-bff/App/routes/users.js @@ -3,39 +3,81 @@ * Licensed under the MIT License. */ -var express = require('express'); -var router = express.Router(); +const express = require('express'); +const router = express.Router(); -var fetch = require('../fetch'); -var { BACKEND_API_ENDPOINT } = require('../authConfig'); +const isAuthenticated = require('../util/isAuthenticated') -// custom middleware to check auth state -function isAuthenticated(req, res, next) { - if (!req.session.isAuthenticated) { - return res.redirect('/auth/signin'); // redirect to sign-in route - } - - next(); -} +const { BACKEND_API_ENDPOINT, msalConfig, REDIRECT_URI} = require('../authConfig'); +const authProvider = require("../auth/AuthProvider"); +/** + * This route shows all id token claims for debugging purposes. + * + * @route GET /web/users/id + * + * Renders html page with id token claims. + */ router.get('/id', - isAuthenticated, // check if user is authenticated + isAuthenticated('/web/auth/signin'), // check if user is authenticated async function (req, res, next) { res.render('id', { idTokenClaims: req.session.account.idTokenClaims }); } ); -router.get('/profile', - isAuthenticated, // check if user is authenticated +/** + * This route returns an object containing info about the authentication status. + * + * @route GET /web/users/id + * + * @returns + * isAuthenticated: boolean, + * account: { + * name: string + * } + */ +router.get('/isAuthenticated', + async function (req, res, next) { try { - const response = await fetch("api/test", req.session.accessToken); - res.render('profile', { profile: response }); - } catch (error) { + if (req.session.isAuthenticated) { + res.send({ + isAuthenticated: true, + account: { + name: req.session.account?.name + } + }); + } else { + res.send({ + isAuthenticated: false, + account: null + }) + } + } catch(error) { next(error); } } ); +/** + * This route renders a page containing the accessToken for debugging purposes. + * + * @route GET /web/users/token + */ +if (process.env.ENVIRONMENT === 'development') { + router.get('/token', + isAuthenticated('/web/auth/signin'), + authProvider.acquireToken({ + scopes: [msalConfig.auth.clientId + "/.default"], + redirectUri: REDIRECT_URI + }), + async function (req, res, next) { + res.render('token', {accessToken: req.session.accessToken}); + } + ) +} + + + module.exports = router; \ No newline at end of file diff --git a/backend/web-bff/App/util/isAuthenticated.js b/backend/web-bff/App/util/isAuthenticated.js new file mode 100644 index 00000000..50de4e61 --- /dev/null +++ b/backend/web-bff/App/util/isAuthenticated.js @@ -0,0 +1,22 @@ +/** + * This route checks if the user is authenticated. + * If not, the user is redirected to the supplied route. + * + * @param redirectPath supplied redirect route + * @returns {(function(*, *, *): (*|undefined))|*} + * + * returns a function that takes 3 arguments: req, res, next to be used as express middleware. + */ + +function isAuthenticated(redirectPath) { + return (req, res, next) => { + // If not authenticated, redirect + if (!req.session.isAuthenticated) { + return res.redirect(redirectPath); + } + // If authenticated, execute next function in middleware. + next(); + } +} + +module.exports = isAuthenticated; \ No newline at end of file diff --git a/backend/web-bff/App/views/index.hbs b/backend/web-bff/App/views/index.hbs index 9f23656c..33fa9826 100644 --- a/backend/web-bff/App/views/index.hbs +++ b/backend/web-bff/App/views/index.hbs @@ -1,12 +1,11 @@

{{title}}

{{#if isAuthenticated }}

Hi {{username}}!

- View ID token claims + View ID token claims
- Acquire a token to call the Microsoft Graph API -
- Sign out + view token + Sign out {{else}}

Welcome to {{title}}

- Sign in + Sign in {{/if}} \ No newline at end of file diff --git a/backend/web-bff/App/views/layout.hbs b/backend/web-bff/App/views/layout.hbs index c335d0a4..5ce79239 100644 --- a/backend/web-bff/App/views/layout.hbs +++ b/backend/web-bff/App/views/layout.hbs @@ -4,6 +4,16 @@ {{title}} + diff --git a/backend/web-bff/App/views/profile.hbs b/backend/web-bff/App/views/profile.hbs deleted file mode 100644 index a602c9d5..00000000 --- a/backend/web-bff/App/views/profile.hbs +++ /dev/null @@ -1,14 +0,0 @@ -

Backend API

-

/ endpoint response

- - - {{#each profile}} - - - - - {{/each}} - -
{{@key}}{{this}}
-
-Go back \ No newline at end of file diff --git a/backend/web-bff/App/views/token.hbs b/backend/web-bff/App/views/token.hbs new file mode 100644 index 00000000..ab04c4a5 --- /dev/null +++ b/backend/web-bff/App/views/token.hbs @@ -0,0 +1,7 @@ +

Backend API

+

/token endpoint response

+ +
+Go back \ No newline at end of file diff --git a/backend/web-bff/Dockerfile b/backend/web-bff/Dockerfile new file mode 100644 index 00000000..b8607e0b --- /dev/null +++ b/backend/web-bff/Dockerfile @@ -0,0 +1,11 @@ +FROM node:21-bookworm + +WORKDIR /express-web-bff + +COPY App/package*.json ./ + +RUN npm install + +COPY App/ . + +CMD npm start diff --git a/backend/web-bff/temp-frontend/.eslintrc.cjs b/backend/web-bff/temp-frontend/.eslintrc.cjs new file mode 100644 index 00000000..d6c95379 --- /dev/null +++ b/backend/web-bff/temp-frontend/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/backend/web-bff/temp-frontend/.gitignore b/backend/web-bff/temp-frontend/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/backend/web-bff/temp-frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/backend/web-bff/temp-frontend/README.md b/backend/web-bff/temp-frontend/README.md new file mode 100644 index 00000000..b2224872 --- /dev/null +++ b/backend/web-bff/temp-frontend/README.md @@ -0,0 +1,3 @@ +# Testing frontend + +Our actual frontend is a bit too complex to test basic features in. This is why this small react project exists. \ No newline at end of file diff --git a/backend/web-bff/temp-frontend/index.html b/backend/web-bff/temp-frontend/index.html new file mode 100644 index 00000000..e4b78eae --- /dev/null +++ b/backend/web-bff/temp-frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/backend/web-bff/temp-frontend/package-lock.json b/backend/web-bff/temp-frontend/package-lock.json new file mode 100644 index 00000000..9000963f --- /dev/null +++ b/backend/web-bff/temp-frontend/package-lock.json @@ -0,0 +1,4223 @@ +{ + "name": "temp-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "temp-frontend", + "version": "0.0.0", + "dependencies": { + "axios": "^1.6.8", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.23.1" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "typescript": "^5.2.2", + "vite": "^5.2.0" + } + }, + "node_modules/@babel/core": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.5.tgz", + "integrity": "sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.2", + "@babel/generator": "^7.24.5", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.24.5", + "@babel/helpers": "^7.24.5", + "@babel/parser": "^7.24.5", + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.5", + "@babel/types": "^7.24.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/code-frame": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", + "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.24.2", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/compat-data": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", + "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/generator": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.5.tgz", + "integrity": "sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.5", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-module-imports": { + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", + "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-module-transforms": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.5.tgz", + "integrity": "sha512-9GxeY8c2d2mdQUP1Dye0ks3VDyIMS98kt/llQ2nUId8IsWqTF0l1LkSX0/uP7l7MCDrzXS009Hyhe2gzTiGW8A==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.24.3", + "@babel/helper-simple-access": "^7.24.5", + "@babel/helper-split-export-declaration": "^7.24.5", + "@babel/helper-validator-identifier": "^7.24.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-simple-access": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.5.tgz", + "integrity": "sha512-uH3Hmf5q5n7n8mz7arjUlDOCbttY/DW4DYhE6FUsjKJ/oYC1kQQUvwEQWxRwUpX9qQKRXeqLwWxrqilMrf32sQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", + "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helpers": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.5.tgz", + "integrity": "sha512-CiQmBMMpMQHwM5m01YnrM6imUG1ebgYJ+fAIW4FZe6m4qHTPaRHti+R8cggAwkdz4oXhtO4/K9JWlh+8hIfR2Q==", + "dev": true, + "dependencies": { + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.5", + "@babel/types": "^7.24.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/highlight": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.5.tgz", + "integrity": "sha512-8lLmua6AVh/8SLJRRVD6V8p73Hir9w5mJrhE+IPpILG31KKlI9iz5zmBYKcWPS59qSfgP9RaSBQSHHE81WKuEw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.24.5", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/template": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/traverse": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.5.tgz", + "integrity": "sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.2", + "@babel/generator": "^7.24.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.24.5", + "@babel/parser": "^7.24.5", + "@babel/types": "^7.24.5", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@babel/core/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@babel/core/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/core/node_modules/browserslist": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/@babel/core/node_modules/caniuse-lite": { + "version": "1.0.30001617", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001617.tgz", + "integrity": "sha512-mLyjzNI9I+Pix8zwcrpxEbGlfqOkF9kM3ptzmKNw5tizSyYwMe+nGLTqMK9cO+0E+Bh6TsBxNAaHWEM8xwSsmA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/@babel/core/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/core/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/core/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@babel/core/node_modules/electron-to-chromium": { + "version": "1.4.763", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.763.tgz", + "integrity": "sha512-k4J8NrtJ9QrvHLRo8Q18OncqBCB7tIUyqxRcJnlonQ0ioHKYB988GcDFF3ZePmnb8eHEopDs/wPHR/iGAFgoUQ==", + "dev": true + }, + "node_modules/@babel/core/node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@babel/core/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/core/node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/core/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/core/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/@babel/core/node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/core/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@babel/core/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/core/node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/@babel/core/node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/core/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/core/node_modules/update-browserslist-db": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.15.tgz", + "integrity": "sha512-K9HWH62x3/EalU1U6sjSZiylm9C8tgq2mSvshZpqc7QE69RaA2qjhkW2HlNA0tFpEbtyFz7HTqbSdN4MSwUodA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/@babel/core/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", + "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz", + "integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", + "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.5.tgz", + "integrity": "sha512-RtCJoUO2oYrYwFPtR1/jkoBEcFuI1ae9a9IMxeyAVa3a1Ap4AnxmyIKG2b2FaJKqkidw/0cxRbWN+HOs6ZWd1w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self/node_modules/@babel/helper-plugin-utils": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.5.tgz", + "integrity": "sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.1.tgz", + "integrity": "sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source/node_modules/@babel/helper-plugin-utils": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.5.tgz", + "integrity": "sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", + "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.24.1", + "@babel/helper-validator-identifier": "^7.24.5", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", + "integrity": "sha512-es2g3dq6Nb07iFxGk5GuHN20RwBZOsuDQN7izWIisUcv9r+d2C5jQxqmgkdebXgReWfiyUabcki6Fg77mSNrig==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", + "integrity": "sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.2.tgz", + "integrity": "sha512-Btgg89dAnqD4vV7R3hlwOxgqobUQKgx3MmrQRi0yYbs/P0ym8XozIAlkqVilPqHQwXs4e9Tf63rrCgl58BcO4w==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.8.0.tgz", + "integrity": "sha512-gFTT+ezJmkwutUPmB0skOj3GZJtlEGnlssems4AjkVweUPGj7jRwwqg0Hhg7++kPGJqKtTYx+R05Ftww372aIg==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.8.0", + "@typescript-eslint/type-utils": "7.8.0", + "@typescript-eslint/utils": "7.8.0", + "@typescript-eslint/visitor-keys": "7.8.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.8.0.tgz", + "integrity": "sha512-KgKQly1pv0l4ltcftP59uQZCi4HUYswCLbTqVZEJu7uLX8CTLyswqMLqLN+2QFz4jCptqWVV4SB7vdxcH2+0kQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.8.0", + "@typescript-eslint/types": "7.8.0", + "@typescript-eslint/typescript-estree": "7.8.0", + "@typescript-eslint/visitor-keys": "7.8.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.8.0.tgz", + "integrity": "sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.8.0.tgz", + "integrity": "sha512-5pfUCOwK5yjPaJQNy44prjCwtr981dO8Qo9J9PwYXZ0MosgAbfEMB008dJ5sNo3+/BN6ytBPuSvXUg9SAqB0dg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.8.0", + "@typescript-eslint/visitor-keys": "7.8.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/@typescript-eslint/parser/node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.8.0.tgz", + "integrity": "sha512-viEmZ1LmwsGcnr85gIq+FCYI7nO90DVbE37/ll51hjv9aG+YZMb4WDE2fyWpUR4O/UrhGRpYXK/XajcGTk2B8g==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.8.0", + "@typescript-eslint/visitor-keys": "7.8.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/scope-manager/node_modules/@typescript-eslint/types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.8.0.tgz", + "integrity": "sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.8.0.tgz", + "integrity": "sha512-H70R3AefQDQpz9mGv13Uhi121FNMh+WEaRqcXTX09YEDky21km4dV1ZXJIp8QjXc4ZaVkXVdohvWDzbnbHDS+A==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "7.8.0", + "@typescript-eslint/utils": "7.8.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.8.0.tgz", + "integrity": "sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.8.0.tgz", + "integrity": "sha512-5pfUCOwK5yjPaJQNy44prjCwtr981dO8Qo9J9PwYXZ0MosgAbfEMB008dJ5sNo3+/BN6ytBPuSvXUg9SAqB0dg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.8.0", + "@typescript-eslint/visitor-keys": "7.8.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/@typescript-eslint/type-utils/node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.8.0.tgz", + "integrity": "sha512-L0yFqOCflVqXxiZyXrDr80lnahQfSOfc9ELAAZ75sqicqp2i36kEZZGuUymHNFoYOqxRT05up760b4iGsl02nQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.15", + "@types/semver": "^7.5.8", + "@typescript-eslint/scope-manager": "7.8.0", + "@typescript-eslint/types": "7.8.0", + "@typescript-eslint/typescript-estree": "7.8.0", + "semver": "^7.6.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@typescript-eslint/utils/node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.8.0.tgz", + "integrity": "sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.8.0.tgz", + "integrity": "sha512-5pfUCOwK5yjPaJQNy44prjCwtr981dO8Qo9J9PwYXZ0MosgAbfEMB008dJ5sNo3+/BN6ytBPuSvXUg9SAqB0dg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.8.0", + "@typescript-eslint/visitor-keys": "7.8.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils/node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/@typescript-eslint/utils/node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.8.0.tgz", + "integrity": "sha512-q4/gibTNBQNA0lGyYQCmWRS5D15n8rXh4QjK3KV+MBPlTYHpfBUT3D3PaPR/HeNiI9W6R7FvlkcGhNyAoP+caA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.8.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/@typescript-eslint/types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.8.0.tgz", + "integrity": "sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz", + "integrity": "sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.5", + "@babel/plugin-transform-react-jsx-self": "^7.23.3", + "@babel/plugin-transform-react-jsx-source": "^7.23.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.7.tgz", + "integrity": "sha512-yrj+KInFmwuQS2UQcg1SF83ha1tuHC1jMQbRNyuWtlEzzKRDgAl7L4Yp4NlDUZTZNlWvHEzOtJhMi40R7JxcSw==", + "dev": true, + "peerDependencies": { + "eslint": ">=7" + } + }, + "node_modules/eslint/node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/eslint/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/eslint/node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/eslint/node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/eslint/node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "dev": true + }, + "node_modules/eslint/node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eslint/node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eslint/node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eslint/node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/eslint/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/eslint/node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/eslint/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/eslint/node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/eslint/node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eslint/node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/eslint/node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/eslint/node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint/node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/eslint/node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/eslint/node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/eslint/node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/eslint/node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/eslint/node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, + "node_modules/eslint/node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/eslint/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/eslint/node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/eslint/node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/eslint/node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint/node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint/node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/eslint/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint/node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/eslint/node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/eslint/node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/eslint/node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/eslint/node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/eslint/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint/node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/eslint/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint/node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/eslint/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint/node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/eslint/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint/node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/eslint/node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/eslint/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/eslint/node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint/node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/eslint/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.23.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.1.tgz", + "integrity": "sha512-fzcOaRF69uvqbbM7OhvQyBTFDVrrGlsFdS3AL+1KfIBtGETibHzi3FkoTRyiDJnWNc2VxrfvR+657ROHjaNjqQ==", + "dependencies": { + "@remix-run/router": "1.16.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.23.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.23.1.tgz", + "integrity": "sha512-utP+K+aSTtEdbWpC+4gxhdlPFwuEfDKq8ZrPFU65bbRJY+l706qjR7yaidBpo3MSeA/fzwbXWbKBI6ftOnP3OQ==", + "dependencies": { + "@remix-run/router": "1.16.1", + "react-router": "6.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", + "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.20.1", + "postcss": "^8.4.38", + "rollup": "^4.13.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz", + "integrity": "sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-android-arm64": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.2.tgz", + "integrity": "sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.2.tgz", + "integrity": "sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.2.tgz", + "integrity": "sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.2.tgz", + "integrity": "sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.2.tgz", + "integrity": "sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.2.tgz", + "integrity": "sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.2.tgz", + "integrity": "sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.2.tgz", + "integrity": "sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.2.tgz", + "integrity": "sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.2.tgz", + "integrity": "sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.2.tgz", + "integrity": "sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.2.tgz", + "integrity": "sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.2.tgz", + "integrity": "sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.2.tgz", + "integrity": "sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/vite/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.2.tgz", + "integrity": "sha512-TGGO7v7qOq4CYmSBVEYpI1Y5xDuCEnbVC5Vth8mOsW0gDSzxNrVERPc790IGHsrT2dQSimgMr9Ub3Y1Jci5/8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/vite/node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/vite/node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vite/node_modules/rollup": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.17.2.tgz", + "integrity": "sha512-/9ClTJPByC0U4zNLowV1tMBe8yMEAxewtR3cUNX5BoEpGH3dQEWpJLr6CLp0fPdYRF/fzVOgvDb1zXuakwF5kQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.17.2", + "@rollup/rollup-android-arm64": "4.17.2", + "@rollup/rollup-darwin-arm64": "4.17.2", + "@rollup/rollup-darwin-x64": "4.17.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.17.2", + "@rollup/rollup-linux-arm-musleabihf": "4.17.2", + "@rollup/rollup-linux-arm64-gnu": "4.17.2", + "@rollup/rollup-linux-arm64-musl": "4.17.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.17.2", + "@rollup/rollup-linux-riscv64-gnu": "4.17.2", + "@rollup/rollup-linux-s390x-gnu": "4.17.2", + "@rollup/rollup-linux-x64-gnu": "4.17.2", + "@rollup/rollup-linux-x64-musl": "4.17.2", + "@rollup/rollup-win32-arm64-msvc": "4.17.2", + "@rollup/rollup-win32-ia32-msvc": "4.17.2", + "@rollup/rollup-win32-x64-msvc": "4.17.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/vite/node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/backend/web-bff/temp-frontend/package.json b/backend/web-bff/temp-frontend/package.json new file mode 100644 index 00000000..af51b155 --- /dev/null +++ b/backend/web-bff/temp-frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "temp-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --cors true", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.23.1", + "axios": "^1.6.8" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "typescript": "^5.2.2", + "vite": "^5.2.0" + } +} diff --git a/backend/web-bff/temp-frontend/public/vite.svg b/backend/web-bff/temp-frontend/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/backend/web-bff/temp-frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/web-bff/temp-frontend/src/App.css b/backend/web-bff/temp-frontend/src/App.css new file mode 100644 index 00000000..b9d355df --- /dev/null +++ b/backend/web-bff/temp-frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/backend/web-bff/temp-frontend/src/App.tsx b/backend/web-bff/temp-frontend/src/App.tsx new file mode 100644 index 00000000..3e8022d9 --- /dev/null +++ b/backend/web-bff/temp-frontend/src/App.tsx @@ -0,0 +1,46 @@ +import {useEffect, useState} from 'react' +import axios from 'axios' +import {Link} from "react-router-dom" + +import './App.css' + + +function App() { + + const [auth, setAuth] + = useState<{ isAuthenticated:boolean, account: { name:string } | null} | null>(null) + + useEffect(() => { + axios.get('http://localhost:3000/web/users/isAuthenticated', {withCredentials: true}).then(({data}) => { + console.log(data) + setAuth(data); + }) + }) + + if (auth === null) { + return ( + <> +

Loading...

+ + ) + + } else if (auth.isAuthenticated) { + return ( + <> +

Logged in!

+

You are logged in as {auth && auth.account?.name ? auth.account.name : null}

+ + ) + } else { + return ( + <> +

Welcome, please login

+ Login + + ) + } + + +} + +export default App diff --git a/backend/web-bff/temp-frontend/src/assets/react.svg b/backend/web-bff/temp-frontend/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/backend/web-bff/temp-frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/web-bff/temp-frontend/src/index.css b/backend/web-bff/temp-frontend/src/index.css new file mode 100644 index 00000000..6119ad9a --- /dev/null +++ b/backend/web-bff/temp-frontend/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/backend/web-bff/temp-frontend/src/main.tsx b/backend/web-bff/temp-frontend/src/main.tsx new file mode 100644 index 00000000..a561b2f8 --- /dev/null +++ b/backend/web-bff/temp-frontend/src/main.tsx @@ -0,0 +1,11 @@ + +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import './index.css' +import {BrowserRouter} from "react-router-dom"; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/backend/web-bff/temp-frontend/src/vite-env.d.ts b/backend/web-bff/temp-frontend/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/backend/web-bff/temp-frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/backend/web-bff/temp-frontend/tsconfig.json b/backend/web-bff/temp-frontend/tsconfig.json new file mode 100644 index 00000000..a7fc6fbf --- /dev/null +++ b/backend/web-bff/temp-frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/backend/web-bff/temp-frontend/tsconfig.node.json b/backend/web-bff/temp-frontend/tsconfig.node.json new file mode 100644 index 00000000..97ede7ee --- /dev/null +++ b/backend/web-bff/temp-frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/backend/web-bff/temp-frontend/vite.config.ts b/backend/web-bff/temp-frontend/vite.config.ts new file mode 100644 index 00000000..5a33944a --- /dev/null +++ b/backend/web-bff/temp-frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/dind-chart.png b/dind-chart.png deleted file mode 100644 index 45000d2f..00000000 Binary files a/dind-chart.png and /dev/null differ diff --git a/docker-compose.yaml b/docker-compose.yaml index 16bb1633..f9541be1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -56,7 +56,6 @@ services: networks: docker_network: ipv4_address: 10.5.0.13 - volumes: postgres-data: networks: @@ -66,3 +65,4 @@ networks: config: - subnet: 10.5.0.0/16 gateway: 10.5.0.1 + diff --git a/docker.env.template b/docker.env.template deleted file mode 100644 index b81bbf3e..00000000 --- a/docker.env.template +++ /dev/null @@ -1,2 +0,0 @@ -PGU= -PGP= diff --git a/envBuilder.py b/envBuilder.py new file mode 100644 index 00000000..9b05ba87 --- /dev/null +++ b/envBuilder.py @@ -0,0 +1,54 @@ + + +class envBuilder(): + def __init__(self): + self.env = {} + self.javaEnv = {'client-secret': ['azure.activedirectory.b2c.client-secret'],'client-id':['azure.activedirectory.client-id'],'tenant-id':['azure.activedirectory.tenant-id'],'PGP':['spring.datasource.password'],'PGU':['spring.datasource.username']} + self.expressEnv = {'URI':['REDIRECT_URI','FRONTEND_URI','BACKEND_API_ENDPOINT'], + 'client-id':['CLIENT_ID'],'client-secret':['CLIENT_SECRET'], + 'tenant-id':['TENANT_ID'],'PGP':['DB_PASSWORD'],'PGU':['DB_USER'],'DB_HOST':['DB_HOST'], + 'DB_PORT':['DB_PORT'],'DB_NAME':['DB_NAME'],'EXPRESS_SESSION_SECRET':['EXPRESS_SESSION_SECRET']} + self.javaEnvLocation = 'backend/app/src/main/resources/application-secrets.properties' + self.expressEnvLocation = 'backend/web-bff/App/.env' + def readEnv(self): + with open('.env', 'r') as file: + for line in file: + [key, value] = line.split('=') + self.env[key] = value + + def javaBuilder(self): + with open(self.javaEnvLocation, 'a+') as file: + for key in self.javaEnv: + if key in self.env: + value = self.env[key] + if value == '': + print(f'{key} is empty') + else: + for envName in self.javaEnv[key]: + file.seek(0) + if sum(line.count(f'{envName}') for line in file) == 0: + file.write(f'{envName}={value}\n') + else : + print(f'{key} not found in .env file') + + def expressBuilder(self): + with open(self.expressEnvLocation, 'a+') as file: + for key in self.expressEnv: + if key in self.env: + value = self.env[key] + if value == '': + print(f'{key} is empty') + else: + for envName in self.expressEnv[key]: + file.seek(0) + if sum(line.count(f'{envName}') for line in file) == 0: + file.write(f'{envName}={value}\n') + else : + print(f'{key} not found in .env file') + + +if __name__ == '__main__': + env = envBuilder() + env.readEnv() + env.javaBuilder() + env.expressBuilder() diff --git a/frontend/.gitignore b/frontend/.gitignore index 98497d56..a851d9f2 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -5,6 +5,7 @@ /.pnp .pnp.js package-lock.json +/package-lock.json # testing /coverage diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dc105cfb..bda8ea16 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,10 +24,14 @@ "@vitejs/plugin-react": "^4.2.1", "antd": "^5.14.2", "axios": "^1.6.7", + "file-saver": "^2.0.5", "framer-motion": "^11.0.24", "highlight.js": "^11.9.0", + "http-proxy-middleware": "^3.0.0", "i18next-localstorage-cache": "^1.1.1", + "jszip": "^3.10.1", "lowlight": "^3.1.0", + "papaparse": "^5.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.0.5", @@ -43,6 +47,8 @@ "devDependencies": { "@babel/preset-env": "^7.24.5", "@testing-library/react": "^14.2.2", + "@types/file-saver": "^2.0.7", + "@types/papaparse": "^5.3.14", "@types/react-syntax-highlighter": "^15.5.11", "cypress": "^13.9.0", "identity-obj-proxy": "^3.0.0", @@ -3253,6 +3259,12 @@ "@types/estree": "*" } }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3270,6 +3282,14 @@ "@types/unist": "*" } }, + "node_modules/@types/http-proxy": { + "version": "1.17.14", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", + "integrity": "sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -3340,6 +3360,15 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/papaparse": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", + "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", @@ -3773,9 +3802,9 @@ } }, "node_modules/aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.0.tgz", + "integrity": "sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==", "dev": true }, "node_modules/axios": { @@ -4073,7 +4102,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -4366,9 +4394,9 @@ } }, "node_modules/cli-table3": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.4.tgz", - "integrity": "sha512-Lm3L0p+/npIQWNIiyF/nAn7T5dnOwR3xNTHXYEBFBFVPXzCVNZ5lqEC/1eo/EVfpDsQ1I+TX4ORPQgp+UI0CRw==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", "dev": true, "dependencies": { "string-width": "^4.2.0" @@ -4508,9 +4536,9 @@ } }, "node_modules/core-js-compat": { - "version": "3.37.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.0.tgz", - "integrity": "sha512-vYq4L+T8aS5UuFg4UwDhc7YNRWVeVZwltad9C/jV3R2LgVOpS9BDr7l/WL6BN0dbV3k1XejPTHqqEzJgsa0frA==", + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", + "integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==", "dev": true, "dependencies": { "browserslist": "^4.23.0" @@ -4521,10 +4549,9 @@ } }, "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "node_modules/create-jest": { "version": "29.7.0", @@ -5252,336 +5279,6 @@ "@esbuild/win32-x64": "0.19.12" } }, - "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", - "cpu": [ - "mips64el" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/esbuild/node_modules/@esbuild/win32-x64": { "version": "0.19.12", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", @@ -5680,6 +5377,11 @@ "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", "dev": true }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5978,11 +5680,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -6338,9 +6044,9 @@ } }, "node_modules/hasown": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", - "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": { "function-bind": "^1.1.2" }, @@ -6497,6 +6203,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -6511,6 +6230,33 @@ "node": ">= 6" } }, + "node_modules/http-proxy-middleware": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.0.tgz", + "integrity": "sha512-36AV1fIaI2cWRzHo+rbcxhe3M3jUDCNzc4D5zRl57sEWRAxdXYtw7FSQKYY6PDKssiAKjLYypbssHk+xs/kMXw==", + "dependencies": { + "@types/http-proxy": "^1.17.10", + "debug": "^4.3.4", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.5" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/http-signature": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", @@ -6617,6 +6363,11 @@ } ] }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/import-local": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", @@ -6666,8 +6417,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "2.0.0", @@ -6838,6 +6588,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -6856,6 +6614,17 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -6896,7 +6665,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -9359,6 +9127,17 @@ "verror": "1.10.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -9386,6 +9165,14 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -10309,7 +10096,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -10570,6 +10356,16 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "node_modules/papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + }, "node_modules/parse-entities": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.1.tgz", @@ -10678,7 +10474,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -10804,6 +10599,11 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -11632,6 +11432,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -11889,8 +11708,7 @@ "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", @@ -12004,24 +11822,9 @@ } }, "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -12095,6 +11898,11 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12290,6 +12098,14 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", @@ -12494,7 +12310,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -12954,6 +12769,11 @@ "react": "^16.8.0 || ^17 || ^18" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -12997,6 +12817,12 @@ "extsprintf": "^1.2.0" } }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true + }, "node_modules/vfile": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 474de460..698fb3c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,10 +19,14 @@ "@vitejs/plugin-react": "^4.2.1", "antd": "^5.14.2", "axios": "^1.6.7", + "file-saver": "^2.0.5", "framer-motion": "^11.0.24", "highlight.js": "^11.9.0", + "http-proxy-middleware": "^3.0.0", "i18next-localstorage-cache": "^1.1.1", + "jszip": "^3.10.1", "lowlight": "^3.1.0", + "papaparse": "^5.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.0.5", @@ -77,6 +81,8 @@ "devDependencies": { "@babel/preset-env": "^7.24.5", "@testing-library/react": "^14.2.2", + "@types/file-saver": "^2.0.7", + "@types/papaparse": "^5.3.14", "@types/react-syntax-highlighter": "^15.5.11", "cypress": "^13.9.0", "identity-obj-proxy": "^3.0.0", diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index a11777cc..7b8b3186 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon1.ico b/frontend/public/favicon1.ico new file mode 100644 index 00000000..44694fb5 Binary files /dev/null and b/frontend/public/favicon1.ico differ diff --git a/frontend/public/logo192.png b/frontend/public/logo192.png index fc44b0a3..3034ab15 100644 Binary files a/frontend/public/logo192.png and b/frontend/public/logo192.png differ diff --git a/frontend/public/logo512.png b/frontend/public/logo512.png index a4e47a65..5682ceb6 100644 Binary files a/frontend/public/logo512.png and b/frontend/public/logo512.png differ diff --git a/frontend/src/@types/appTypes.ts b/frontend/src/@types/appTypes.ts index b2691ad5..91617758 100644 --- a/frontend/src/@types/appTypes.ts +++ b/frontend/src/@types/appTypes.ts @@ -1,5 +1,10 @@ - +export enum LoginStatus { + LOGIN_IN_PROGRESS = "login_busy", + LOGOUT_IN_PROGRESS = "logout_busy", + LOGGED_IN = "login_done", + LOGGED_OUT = "logout_done" +} export enum Themes { diff --git a/frontend/src/@types/requests.d.ts b/frontend/src/@types/requests.d.ts index 63f6846a..67dad1cc 100644 --- a/frontend/src/@types/requests.d.ts +++ b/frontend/src/@types/requests.d.ts @@ -1,48 +1,63 @@ import type {ProjectFormData} from "../pages/projectCreate/components/ProjectCreateService"; +import {Account} from "../providers/AuthProvider"; /** * Routes used to make API calls */ export enum ApiRoutes { - USER_COURSES = "api/courses", - COURSES = "api/courses", + + AUTH_INFO = "/web/users/isAuthenticated", + + USER_COURSES = "/web/api/courses", + COURSES = "/web/api/courses", + + + COURSE = "/web/api/courses/:courseId", + COURSE_MEMBERS = "/web/api/courses/:courseId/members", + COURSE_MEMBER = "/web/api/courses/:courseId/members/:userId", + COURSE_PROJECTS = "/web/api/courses/:id/projects", + COURSE_CLUSTERS = "/web/api/courses/:id/clusters", + COURSE_GRADES = "/web/api/courses/:id/grades", + COURSE_LEAVE = "/web/api/courses/:courseId/leave", + COURSE_COPY = "/web/api/courses/:courseId/copy", + COURSE_JOIN = "/web/api/courses/:courseId/join/:courseKey", + COURSE_JOIN_WITHOUT_KEY = "/web/api/courses/:courseId/join", + COURSE_JOIN_LINK = "/web/api/courses/:courseId/joinKey", + + PROJECTS = "/web/api/projects", + PROJECT = "/web/api/projects/:id", + PROJECT_CREATE = "/web/api/courses/:courseId/projects", + PROJECT_TESTS = "/web/api/projects/:id/tests", + PROJECT_SUBMISSIONS = "/web/api/projects/:id/submissions", + PROJECT_SCORE = "/web/api/projects/:id/groups/:groupId/score", + PROJECT_GROUP = "/web/api/projects/:id/groups/:groupId", + PROJECT_GROUPS = "/web/api/projects/:id/groups", + PROJECT_GROUP_SUBMISSIONS = "/web/api/projects/:projectId/submissions/:groupId", + PROJECT_TEST_SUBMISSIONS = "/web/api/projects/:projectId/adminsubmissions", + PROJECT_TESTS_UPLOAD = "/web/api/projects/:id/tests/extrafiles", + PROJECT_SUBMIT = "/web/api/projects/:id/submit", + PROJECT_DOWNLOAD_ALL_SUBMISSIONS = "/web/api/projects/:id/submissions/files", - COURSE = "api/courses/:courseId", - COURSE_MEMBERS = "api/courses/:courseId/members", - COURSE_MEMBER = "api/courses/:courseId/members/:userId", - COURSE_PROJECTS = "api/courses/:id/projects", - COURSE_CLUSTERS = "api/courses/:id/clusters", - COURSE_GRADES = '/api/courses/:id/grades', - COURSE_LEAVE = "api/courses/:courseId/leave", - COURSE_COPY = "/api/courses/:courseId/copy", - - PROJECTS = "api/projects", - PROJECT = "api/projects/:id", - PROJECT_CREATE = "api/courses/:courseId/projects", - PROJECT_TESTS = "api/projects/:id/tests", - PROJECT_SUBMISSIONS = "api/projects/:id/submissions", - PROJECT_SCORE = "api/projects/:id/groups/:groupId/score", - PROJECT_GROUP = "api/projects/:id/groups/:groupId", - PROJECT_GROUPS = "api/projects/:id/groups", - PROJECT_GROUP_SUBMISSIONS = "api/projects/:projectId/submissions/:groupId", - - SUBMISSION = "api/submissions/:id", - SUBMISSION_FILE = "api/submissions/:id/file", - SUBMISSION_STRUCTURE_FEEDBACK= "/api/submissions/:id/structurefeedback", - SUBMISSION_DOCKER_FEEDBACK= "/api/submissions/:id/dockerfeedback", - SUBMISSION_ARTIFACT="/api/submissions/:id/artifacts", - - - CLUSTER = "api/clusters/:id", - - GROUP = "api/groups/:id", - GROUP_MEMBERS = "api/groups/:id/members", - GROUP_MEMBER = "api/groups/:id/members/:userId", - GROUP_SUBMISSIONS = "api/projects/:id/groups/:id/submissions", - - USER = "api/users/:id", - USERS = "api/users", - USER_AUTH = "api/user", + + SUBMISSION = "/web/api/submissions/:id", + SUBMISSION_FILE = "/web/api/submissions/:id/file", + SUBMISSION_STRUCTURE_FEEDBACK= "/web/api/submissions/:id/structurefeedback", + SUBMISSION_DOCKER_FEEDBACK= "/web/api/submissions/:id/dockerfeedback", + SUBMISSION_ARTIFACT="/web/api/submissions/:id/artifacts", + + + + CLUSTER = "/web/api/clusters/:id", + CLUSTER_FILL = "/web/api/clusters/:id/fill", + + GROUP = "/web/api/groups/:id", + GROUP_MEMBERS = "/web/api/groups/:id/members", + GROUP_MEMBER = "/web/api/groups/:id/members/:userId", + GROUP_SUBMISSIONS = "/web/api/projects/:id/groups/:id/submissions", + + USER = "/web/api/users/:id", + USERS = "/web/api/users", + USER_AUTH = "/web/api/user", } export type Timestamp = string @@ -63,10 +78,14 @@ export type POST_Requests = { visible: boolean; maxScore: number; deadline: Date | null; -} + visibleAfter: Date | null; +} [ApiRoutes.GROUP_MEMBERS]: { - id: number + id: number + } + [ApiRoutes.PROJECT_SUBMIT]: { + file: FormData } [ApiRoutes.COURSE_CLUSTERS]: { @@ -74,9 +93,11 @@ export type POST_Requests = { capacity: number groupCount: number }, - [ApiRoutes.PROJECT_TESTS]: Omit + [ApiRoutes.PROJECT_TESTS]: Omit [ApiRoutes.COURSE_COPY]: undefined - + [ApiRoutes.COURSE_JOIN]: undefined + [ApiRoutes.COURSE_JOIN_WITHOUT_KEY]: undefined + [ApiRoutes.PROJECT_SCORE]: Omit } /** @@ -84,13 +105,16 @@ export type POST_Requests = { */ export type POST_Responses = { + [ApiRoutes.PROJECT_SUBMIT]: GET_Responses[ApiRoutes.SUBMISSION] [ApiRoutes.COURSES]: GET_Responses[ApiRoutes.COURSE], [ApiRoutes.PROJECT_CREATE]: GET_Responses[ApiRoutes.PROJECT] [ApiRoutes.GROUP_MEMBERS]: GET_Responses[ApiRoutes.GROUP_MEMBERS] [ApiRoutes.COURSE_CLUSTERS]: GET_Responses[ApiRoutes.CLUSTER], [ApiRoutes.PROJECT_TESTS]: GET_Responses[ApiRoutes.PROJECT_TESTS] [ApiRoutes.COURSE_COPY]: GET_Responses[ApiRoutes.COURSE] - + [ApiRoutes.COURSE_JOIN]: {name:string, description: string} + [ApiRoutes.COURSE_JOIN_WITHOUT_KEY]: POST_Responses[ApiRoutes.COURSE_JOIN] + [ApiRoutes.PROJECT_SCORE]: GET_Responses[ApiRoutes.PROJECT_SCORE] } /** @@ -103,6 +127,8 @@ export type DELETE_Requests = { [ApiRoutes.COURSE_LEAVE]: undefined [ApiRoutes.COURSE_MEMBER]: undefined [ApiRoutes.PROJECT_TESTS]: undefined + [ApiRoutes.COURSE_JOIN_LINK]: undefined + [ApiRoutes.PROJECT_TESTS_UPLOAD]: undefined } @@ -115,32 +141,48 @@ export type PUT_Requests = { [ApiRoutes.COURSE_MEMBER]: { relation: CourseRelation } [ApiRoutes.PROJECT_SCORE]: { score: number | null , feedback: string}, [ApiRoutes.PROJECT_TESTS]: POST_Requests[ApiRoutes.PROJECT_TESTS] -} + [ApiRoutes.USER]: { + name: string + surname: string + email: string + role: UserRole + } + [ApiRoutes.CLUSTER_FILL]: { + [groupName:string]: number[] /* userId[] */ + } + [ApiRoutes.COURSE_JOIN_LINK]: undefined + [ApiRoutes.PROJECT_TESTS_UPLOAD]: FormData +} + export type PUT_Responses = { [ApiRoutes.COURSE]: GET_Responses[ApiRoutes.COURSE] [ApiRoutes.PROJECT]: GET_Responses[ApiRoutes.PROJECT] + [ApiRoutes.USER]: GET_Responses[ApiRoutes.USER] [ApiRoutes.COURSE_MEMBER]: GET_Responses[ApiRoutes.COURSE_MEMBERS] [ApiRoutes.PROJECT_SCORE]: GET_Responses[ApiRoutes.PROJECT_SCORE] [ApiRoutes.PROJECT_TESTS]: GET_Responses[ApiRoutes.PROJECT_TESTS] + [ApiRoutes.CLUSTER_FILL]: PUT_Requests[ApiRoutes.CLUSTER_FILL] + [ApiRoutes.COURSE_JOIN_LINK]: ApiRoutes.COURSE_JOIN + [ApiRoutes.PROJECT_TESTS_UPLOAD]: undefined } type CourseTeacher = { - name: string - surname: string - url: string, + name: string + surname: string + url: string, } type Course = { - courseUrl: string - name: string + courseUrl: string + name: string } export type DockerStatus = "no_test" | "running" | "finished" | "aborted" -export type ProjectStatus = "correct" | "incorrect" | "not started" +export type ProjectStatus = "correct" | "incorrect" | "not started" | "no group" export type CourseRelation = "enrolled" | "course_admin" | "creator" export type UserRole = "student" | "teacher" | "admin" @@ -150,11 +192,12 @@ type SubTest = { correct: string, // verwachte output output: string, // gegenereerde output required: boolean, // of de test verplicht is - succes: boolean, // of de test verplicht is + //FIXME: typo, moet success zijn ipv succes + succes: boolean, // of de test geslaagd is } type DockerFeedback = { - type: "SIMPLE", + type: "SIMPLE", feedback: string, // de logs van de dockerrun allowed: boolean // vat samen of de test geslaagd is of niet } | { @@ -176,26 +219,26 @@ type DockerFeedback = { */ export type GET_Responses = { [ApiRoutes.PROJECT_SUBMISSIONS]: { - feedback: GET_Responses[ApiRoutes.PROJECT_SCORE] | null, - group: GET_Responses[ApiRoutes.GROUP], + feedback: GET_Responses[ApiRoutes.PROJECT_SCORE] | null, + group: GET_Responses[ApiRoutes.GROUP], submission: GET_Responses[ApiRoutes.SUBMISSION] | null // null if no submission yet }[], [ApiRoutes.PROJECT_GROUP_SUBMISSIONS]: GET_Responses[ApiRoutes.SUBMISSION][] + [ApiRoutes.PROJECT_TEST_SUBMISSIONS]: GET_Responses[ApiRoutes.PROJECT_GROUP_SUBMISSIONS] [ApiRoutes.GROUP_SUBMISSIONS]: GET_Responses[ApiRoutes.SUBMISSION] [ApiRoutes.SUBMISSION]: { submissionId: number projectId: number - groupId: number + groupId: number | null structureAccepted: boolean, dockerStatus: DockerStatus, - dockerAccepted: boolean submissionTime: Timestamp projectUrl: ApiRoutes.PROJECT - groupUrl: ApiRoutes.GROUP + groupUrl: ApiRoutes.GROUP | null fileUrl: ApiRoutes.SUBMISSION_FILE structureFeedback: ApiRoutes.SUBMISSION_STRUCTURE_FEEDBACK dockerFeedback: DockerFeedback, - artifactUrl: ApiRoutes.SUBMISSION_ARTIFACT + artifactUrl: ApiRoutes.SUBMISSION_ARTIFACT | null } [ApiRoutes.SUBMISSION_FILE]: BlobPart [ApiRoutes.COURSE_PROJECTS]: GET_Responses[ApiRoutes.PROJECT][] @@ -214,6 +257,7 @@ export type GET_Responses = { testsUrl: string maxScore: number | null visible: boolean + visibleAfter?: Timestamp status?: ProjectStatus progress: { completed: number @@ -226,8 +270,11 @@ export type GET_Responses = { dockerImage: string | null, dockerScript: string | null, dockerTemplate: string | null, - structureTest: string | null + structureTest: string | null, + extraFilesUrl: ApiRoutes.PROJECT_TESTS_UPLOAD + extraFilesName: string } + [ApiRoutes.GROUP]: { groupId: number, capacity: number, @@ -245,14 +292,16 @@ export type GET_Responses = { email: string name: string userId: number + studentNumber: string | null // Null in case of enrolled/student } [ApiRoutes.USERS]: { name: string - userId: number + surname: string + id: number url: string email: string role: UserRole - } + }[] [ApiRoutes.GROUP_MEMBERS]: GET_Responses[ApiRoutes.GROUP_MEMBER][] [ApiRoutes.COURSE_CLUSTERS]: GET_Responses[ApiRoutes.CLUSTER][] @@ -264,7 +313,8 @@ export type GET_Responses = { groupCount: number; createdAt: Timestamp; groups: GET_Responses[ApiRoutes.GROUP][] - courseUrl: ApiRoutes.COURSE + courseUrl: ApiRoutes.COURSE, + lockGroupsAfter: Timestamp | null // means students can't join or leave the group } [ApiRoutes.COURSE]: { description: string @@ -273,7 +323,8 @@ export type GET_Responses = { name: string teacher: CourseTeacher assistents: CourseTeacher[] - joinUrl: string + joinUrl: ApiRoutes.COURSE_JOIN + joinKey: string | null archivedAt: Timestamp | null // null if not archived year: number createdAt: Timestamp @@ -305,10 +356,59 @@ export type GET_Responses = { //[ApiRoutes.PROJECT_GROUP]: GET_Responses[ApiRoutes.CLUSTER_GROUPS][number] [ApiRoutes.PROJECT_GROUPS]: GET_Responses[ApiRoutes.GROUP][] //GET_Responses[ApiRoutes.PROJECT_GROUP][] - [ApiRoutes.PROJECTS]: { - enrolledProjects: {project: GET_Responses[ApiRoutes.PROJECT], status: ProjectStatus}[], - adminProjects: Omit[] - }, + [ApiRoutes.CLUSTER]: { + clusterId: number; + name: string; + capacity: number; + groupCount: number; + createdAt: Timestamp; + groups: GET_Responses[ApiRoutes.GROUP][] + courseUrl: ApiRoutes.COURSE + } + [ApiRoutes.COURSE]: { + description: string + courseId: number + memberUrl: ApiRoutes.COURSE_MEMBERS + name: string + teacher: CourseTeacher + assistents: CourseTeacher[] + joinUrl: string + archivedAt: Timestamp | null // null if not archived + year: number + createdAt: Timestamp + } + [ApiRoutes.COURSE_MEMBERS]: { + relation: CourseRelation, + user: GET_Responses[ApiRoutes.GROUP_MEMBER] + }[], + [ApiRoutes.USER]: { + courseUrl: string + projects_url: string + url: string + role: UserRole + email: string + id: number + name: string + surname: string + studentNumber: string | null // Null in case of enrolled/student + }, + [ApiRoutes.USER_AUTH]: GET_Responses[ApiRoutes.USER], + [ApiRoutes.USER_COURSES]: { + courseId: number, + name: string, + relation: CourseRelation, + memberCount: number, + archivedAt: Timestamp | null, // null if not archived + year: number // Year of the course + url: string + }[], + //[ApiRoutes.PROJECT_GROUP]: GET_Responses[ApiRoutes.CLUSTER_GROUPS][number] + [ApiRoutes.PROJECT_GROUPS]: GET_Responses[ApiRoutes.GROUP][] //GET_Responses[ApiRoutes.PROJECT_GROUP][] + + [ApiRoutes.PROJECTS]: { + enrolledProjects: { project: GET_Responses[ApiRoutes.PROJECT], status: ProjectStatus }[], + adminProjects: Omit[] + }, [ApiRoutes.COURSE_GRADES]: { projectName: string, @@ -321,6 +421,12 @@ export type GET_Responses = { [ApiRoutes.SUBMISSION_STRUCTURE_FEEDBACK]: string | null // Null if no feedback is given [ApiRoutes.SUBMISSION_DOCKER_FEEDBACK]: string | null // Null if no feedback is given - [ApiRoutes.SUBMISSION_ARTIFACT]: Blob // returned het artifact als zip + + [ApiRoutes.COURSE_JOIN]: GET_Responses[ApiRoutes.COURSE] + [ApiRoutes.COURSE_JOIN_WITHOUT_KEY]: GET_Responses[ApiRoutes.COURSE] + [ApiRoutes.PROJECT_TESTS_UPLOAD]: Blob + [ApiRoutes.PROJECT_DOWNLOAD_ALL_SUBMISSIONS]: Blob + [ApiRoutes.AUTH_INFO]: {isAuthenticated:boolean, account: Account} } + diff --git a/frontend/src/@types/routes.ts b/frontend/src/@types/routes.ts index 499dee49..2808d121 100644 --- a/frontend/src/@types/routes.ts +++ b/frontend/src/@types/routes.ts @@ -8,6 +8,7 @@ export enum AppRoutes { PROJECT = "/courses/:courseId/projects/:projectId", PROJECT_CREATE = "/courses/:courseId/create", PROJECT_TESTS = "/courses/:courseId/projects/:projectId/tests", + DOWNLOAD_PROJECT_TESTS = "/courses/:courseId/projects/:projectId/tests/download", COURSE = "/courses/:courseId", NEW_SUBMISSION = "/courses/:courseId/projects/:projectId/submit", EDIT_PROJECT = "/courses/:courseId/projects/:projectId/edit", @@ -16,6 +17,6 @@ export enum AppRoutes { ERROR = "/error", NOT_FOUND = "/not-found", EDIT_ROLE = "/edit-role", - COURSE_INVITE = "/invite/:inviteId", + COURSE_INVITE = "/invite/:courseId", COURSES = "/courses", } diff --git a/frontend/src/@types/types.d.ts b/frontend/src/@types/types.d.ts index daec4a4b..719ad8de 100644 --- a/frontend/src/@types/types.d.ts +++ b/frontend/src/@types/types.d.ts @@ -1,4 +1,11 @@ +declare module "react" { + interface InputHTMLAttributes extends HTMLAttributes { + webkitdirectory?: string; + directory?:string + mozdirectory?: string + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2444d945..2499f4a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,31 +1,24 @@ import AppRouter from "./router/AppRouter" -import { IPublicClientApplication } from "@azure/msal-browser" -import { MsalProvider } from "@azure/msal-react" -import { useNavigate } from "react-router-dom" -import CustomNavigation from "./auth/CustomNavigation" + import Layout from "./components/layout/nav/Layout" import "./i18n/config" import ThemeProvider from "./theme/ThemeProvider" import { AppProvider } from "./providers/AppProvider" +import {AuthProvider} from "./providers/AuthProvider" import { UserProvider } from "./providers/UserProvider" import AppApiProvider from "./providers/AppApiProvider" import ErrorProvider from "./providers/ErrorProvider" -type AppProps = { - pca: IPublicClientApplication -} -function App({ pca }: AppProps) { - const navigate = useNavigate() - const navigationClient = new CustomNavigation(navigate) - pca.setNavigationClient(navigationClient) +function App() { + return (
- + @@ -33,7 +26,7 @@ function App({ pca }: AppProps) { - + diff --git a/frontend/src/components/common/saveDockerForm.tsx b/frontend/src/components/common/saveDockerForm.tsx index 2a616ea4..58062470 100644 --- a/frontend/src/components/common/saveDockerForm.tsx +++ b/frontend/src/components/common/saveDockerForm.tsx @@ -1,32 +1,60 @@ -import { FormInstance } from "antd"; -import { ApiRoutes, POST_Requests } from "../../@types/requests.d"; -import { UseApiType } from "../../hooks/useApi"; +import { FormInstance, GetProp, UploadProps } from "antd" +import { ApiRoutes, POST_Requests } from "../../@types/requests.d" +import { UseApiType } from "../../hooks/useApi" +import apiCall from "../../util/apiFetch" +import { RcFile } from "antd/es/upload" +export type DockerFormData = POST_Requests[ApiRoutes.PROJECT_TESTS] +type FileType = RcFile//Parameters>[0] +const saveDockerForm = async (form: FormInstance, initialDockerValues: DockerFormData | null, API: UseApiType, projectId: string) => { + if (!form.isFieldsTouched(["dockerImage", "dockerScript", "dockerTemplate", "structureTest"])) return null -export type DockerFormData = POST_Requests[ApiRoutes.PROJECT_TESTS] + let data: DockerFormData = form.getFieldsValue(["dockerImage", "dockerScript", "dockerTemplate", "structureTest"]) - -const saveDockerForm = async (form:FormInstance, initialDockerValues: DockerFormData | null, API: UseApiType, projectId:string) => { - if(!form.isFieldsTouched(["dockerImage", 'dockerScript', 'dockerTemplate', 'structureTest'])) return null - - let data:DockerFormData = form.getFieldsValue(['dockerImage', 'dockerScript', 'dockerTemplate', 'structureTest']) - - if(!initialDockerValues) { + if (!initialDockerValues) { // We do a POST request - console.log("POST", data); - return API.POST(ApiRoutes.PROJECT_TESTS, { body: data, pathValues: {id: projectId}}) - } - - if(data.dockerImage === null || data.dockerImage.length === 0 ) { + console.log("POST", data) + await API.POST(ApiRoutes.PROJECT_TESTS, { body: data, pathValues: { id: projectId } }) + } else if (data.dockerImage == null || data.dockerImage.length === 0) { // We do a delete - console.log("DELETE", data); - return API.DELETE(ApiRoutes.PROJECT_TESTS, { pathValues: {id: projectId} }) + console.log("DELETE", data) + await API.DELETE(ApiRoutes.PROJECT_TESTS, { pathValues: { id: projectId } }) + } else { + // We do a PUT + console.log("PUT", data) + await API.PUT(ApiRoutes.PROJECT_TESTS, { body: data, pathValues: { id: projectId }, headers: {} }) } - // We do a PUT - console.log("PUT", data); - return API.PUT(ApiRoutes.PROJECT_TESTS, { body: data, pathValues: {id: projectId}}) + if (form.isFieldTouched("dockerTestDir")) { + const val: FileType | undefined = form.getFieldValue("dockerTestDir")?.[0]?.originFileObj + + if (val === undefined) { + // DELETE + await API.DELETE(ApiRoutes.PROJECT_TESTS_UPLOAD, { pathValues: { id: projectId } }, "message") + } else { + const formData = new FormData() + formData.append("file", val, val.name) + try { + await apiCall.put(ApiRoutes.PROJECT_TESTS_UPLOAD, formData, {id: projectId}) + } catch(err){ + console.error(err); + } + + // await API.PUT( + // ApiRoutes.PROJECT_TESTS_UPLOAD, + // { + // body: formData, + // pathValues: { id: projectId }, + // headers: { + // "Content-Type": "multipart/form-data", + // }, + // }, + // "message" + // ) + } + console.log(val) + } } -export default saveDockerForm \ No newline at end of file +export default saveDockerForm diff --git a/frontend/src/components/forms/ClusterForm.tsx b/frontend/src/components/forms/ClusterForm.tsx index 78f3364d..ffd77fc7 100644 --- a/frontend/src/components/forms/ClusterForm.tsx +++ b/frontend/src/components/forms/ClusterForm.tsx @@ -1,26 +1,57 @@ -import { Form, Input, InputNumber } from "antd" +import { DatePicker, Form, Input, InputNumber } from "antd" import { useTranslation } from "react-i18next" - - const ClusterForm = () => { - const {t} = useTranslation() - - - return <> - - - - - - - - - - - - - + const { t } = useTranslation() + + return ( + <> + + + + + + + + + + + + + + + + + ) } -export default ClusterForm \ No newline at end of file +export default ClusterForm diff --git a/frontend/src/components/forms/projectFormTabs/DockerFormTab.tsx b/frontend/src/components/forms/projectFormTabs/DockerFormTab.tsx index d8ae921d..370ee2b3 100644 --- a/frontend/src/components/forms/projectFormTabs/DockerFormTab.tsx +++ b/frontend/src/components/forms/projectFormTabs/DockerFormTab.tsx @@ -1,11 +1,13 @@ -import { InboxOutlined } from "@ant-design/icons" +import { InboxOutlined, UploadOutlined } from "@ant-design/icons" import { Button, Form, Input, Upload } from "antd" import { TextAreaProps } from "antd/es/input" import { FormInstance } from "antd/lib" import { FC } from "react" import { useTranslation } from "react-i18next" +import { ApiRoutes } from "../../../@types/requests" +import useAppApi from "../../../hooks/useAppApi" -const UploadBtn: React.FC<{ form: FormInstance; fieldName: string; textFieldProps?: TextAreaProps, disabled?:boolean }> = ({ form, fieldName, disabled }) => { +const UploadBtn: React.FC<{ form: FormInstance; fieldName: string; textFieldProps?: TextAreaProps; disabled?: boolean }> = ({ form, fieldName, disabled }) => { const handleFileUpload = (file: File) => { const reader = new FileReader() reader.onload = (e) => { @@ -26,7 +28,12 @@ const UploadBtn: React.FC<{ form: FormInstance; fieldName: string; textFieldProp beforeUpload={handleFileUpload} disabled={disabled} > - +
@@ -34,48 +41,56 @@ const UploadBtn: React.FC<{ form: FormInstance; fieldName: string; textFieldProp } function isValidTemplate(template: string): string { - if(!template?.length) return "" // Template is optional - let atLeastOne = false; // Template should not be empty - const lines = template.split("\n"); - if (lines[0].charAt(0) !== '@') { - return 'Error: The first character of the first line should be "@"'; + if (!template?.length) return "" // Template is optional + let atLeastOne = false // Template should not be empty + const lines = template.split("\n") + if (lines[0].charAt(0) !== "@") { + return 'Error: The first character of the first line should be "@"' } - let isConfigurationLine = false; + let isConfigurationLine = false for (const line of lines) { - if(line.length === 0){ // skip line if empty - continue; + if (line.length === 0) { + // skip line if empty + continue } - if (line.charAt(0) === '@') { - atLeastOne = true; - isConfigurationLine = true; - continue; + if (line.charAt(0) === "@") { + atLeastOne = true + isConfigurationLine = true + continue } if (isConfigurationLine) { - if (line.charAt(0) === '>') { - const isDescription = line.length >= 13 && line.substring(0, 13).toLowerCase() === ">description="; + if (line.charAt(0) === ">") { + const isDescription = line.length >= 13 && line.substring(0, 13).toLowerCase() === ">description=" // option lines - if (line.toLowerCase() !== ">required" && line.toLowerCase() !== ">optional" - && !isDescription) { - return 'Error: Option lines should be either ">Required", ">Optional" or start with ">Description="'; + if (line.toLowerCase() !== ">required" && line.toLowerCase() !== ">optional" && !isDescription) { + return 'Error: Option lines should be either ">Required", ">Optional" or start with ">Description="' } } else { - isConfigurationLine = false; + isConfigurationLine = false } } } if (!atLeastOne) { - return 'Error: Template should not be empty'; + return "Error: Template should not be empty" } - return ''; + return "" } const DockerFormTab: FC<{ form: FormInstance }> = ({ form }) => { const { t } = useTranslation() + const {message} = useAppApi() const dockerImage = Form.useWatch("dockerImage", form) const dockerDisabled = !dockerImage?.length + const normFile = (e: any) => { + console.log('Upload event:', e); + if (Array.isArray(e)) { + return e; + } + return e?.fileList; + }; return ( <> @@ -83,7 +98,6 @@ const DockerFormTab: FC<{ form: FormInstance }> = ({ form }) => { label="Docker image" name="dockerImage" tooltip="TODO write docs for this" - > = ({ form }) => { /> - <> - - - - + + + + - { - const errorMessage = isValidTemplate(value); - return errorMessage === '' ? Promise.resolve() : Promise.reject(new Error(errorMessage)); - }, + { + const errorMessage = isValidTemplate(value) + return errorMessage === "" ? Promise.resolve() : Promise.reject(new Error(errorMessage)) }, - ]} - > - - - + - + + + + + + { + const isPNG = file.type === 'application/zip' + if (!isPNG) { + message.error(`${file.name} is not a zip file`); + return Upload.LIST_IGNORE + } + return false + }} + > + + + ) } diff --git a/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx b/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx index 668d9583..f81fd4c7 100644 --- a/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx +++ b/frontend/src/components/forms/projectFormTabs/GeneralFormTab.tsx @@ -4,55 +4,72 @@ import { FC } from "react" import MarkdownEditor from "../../input/MarkdownEditor" const GeneralFormTab: FC<{ form: FormInstance }> = ({ form }) => { - const { t } = useTranslation() - const description = Form.useWatch("description", form) - - return ( - <> - - - - - - {t("project.change.description")} - - - - - - - - - - - - - - ) + const { t } = useTranslation() + const description = Form.useWatch("description", form) + const visible = Form.useWatch("visible", form) + + return ( + <> + + + + + + {t("project.change.description")} + + + + + + + + {!visible && ( + + + + )} + + + + + + + + + + ) } export default GeneralFormTab diff --git a/frontend/src/components/forms/projectFormTabs/GroupsFormTab.tsx b/frontend/src/components/forms/projectFormTabs/GroupsFormTab.tsx index f138df66..91efd873 100644 --- a/frontend/src/components/forms/projectFormTabs/GroupsFormTab.tsx +++ b/frontend/src/components/forms/projectFormTabs/GroupsFormTab.tsx @@ -1,21 +1,20 @@ -import { Form } from "antd" +import { DatePicker, Form } from "antd" import GroupClusterDropdown from "../../../pages/projectCreate/components/GroupClusterDropdown" import { useParams } from "react-router-dom" import { useTranslation } from "react-i18next" import { FC, useEffect, useState } from "react" import { FormInstance } from "antd/lib" -import apiCall from "../../../util/apiFetch" import { ApiRoutes } from "../../../@types/requests.d" import { ClusterType } from "../../../pages/course/components/groupTab/GroupsCard" import { Spin } from "antd" -import GroupList from "../../../pages/course/components/groupTab/GroupList" import GroupMembersTransfer from "../../other/GroupMembersTransfer" +import useApi from "../../../hooks/useApi" const GroupsFormTab: FC<{ form: FormInstance }> = ({ form }) => { const { courseId } = useParams<{ courseId: string }>() const { t } = useTranslation() const [selectedCluster, setSelectedCluster] = useState(null) - + const API = useApi() const selectedClusterId = Form.useWatch("groupClusterId", form) useEffect(() => { @@ -26,8 +25,9 @@ const GroupsFormTab: FC<{ form: FormInstance }> = ({ form }) => { }, [selectedClusterId]) const fetchCluster = async () => { - const response = await apiCall.get(ApiRoutes.CLUSTER, { id: selectedClusterId }) - setSelectedCluster(response.data) + const response = await API.GET(ApiRoutes.CLUSTER, { pathValues: { id: selectedClusterId } }) + if (!response.success) return + setSelectedCluster(response.response.data) } return ( @@ -40,6 +40,10 @@ const GroupsFormTab: FC<{ form: FormInstance }> = ({ form }) => { { + console.log("Setting clusterId:", clusterId) + form.setFieldValue("groupClusterId", clusterId) + }} /> @@ -47,11 +51,15 @@ const GroupsFormTab: FC<{ form: FormInstance }> = ({ form }) => { <> {selectedCluster ? ( <> - + + + ) : ( diff --git a/frontend/src/components/input/MarkdownTextfield.tsx b/frontend/src/components/input/MarkdownTextfield.tsx index fe03dc43..e88bc313 100644 --- a/frontend/src/components/input/MarkdownTextfield.tsx +++ b/frontend/src/components/input/MarkdownTextfield.tsx @@ -1,4 +1,4 @@ -import { MDXProvider } from "@mdx-js/react" +import Markdown from "react-markdown" import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism" import useApp from "../../hooks/useApp" @@ -7,8 +7,8 @@ import { FC } from "react" const MarkdownTextfield: FC<{ content: string }> = ({ content }) => { const app = useApp() - const components = { - code({ children, className, ...rest }: any) { + const CodeBlock = { + code({ children, className, node, ...rest }: any) { const match = /language-(\w+)/.exec(className || "") return match ? ( = ({ content }) => { style={app.theme === "light" ? oneLight : oneDark} /> ) : ( - + {children} ) }, } - return ( - -
{content}
-
- ) + return {content} } export default MarkdownTextfield diff --git a/frontend/src/components/layout/nav/AuthNav.tsx b/frontend/src/components/layout/nav/AuthNav.tsx index 96974971..f44aba9e 100644 --- a/frontend/src/components/layout/nav/AuthNav.tsx +++ b/frontend/src/components/layout/nav/AuthNav.tsx @@ -1,19 +1,28 @@ -import { useAccount } from "@azure/msal-react" + import { Dropdown, MenuProps, Typography } from "antd" import { useTranslation } from "react-i18next" -import { UserOutlined, BgColorsOutlined, DownOutlined, LogoutOutlined } from "@ant-design/icons" -import { msalInstance } from "../../../index" + +import { UserOutlined, BgColorsOutlined, DownOutlined, LogoutOutlined, PlusOutlined } from "@ant-design/icons" import { useNavigate } from "react-router-dom" import { Themes } from "../../../@types/appTypes" import { AppRoutes } from "../../../@types/routes" import useApp from "../../../hooks/useApp" +import useAuth from "../../../hooks/useAuth" + +import createCourseModal from "../../../pages/index/components/CreateCourseModal" +import useIsTeacher from "../../../hooks/useIsTeacher" +import {BACKEND_SERVER} from "../../../util/backendServer"; const AuthNav = () => { const { t } = useTranslation() const app = useApp() - const auth = useAccount() + + const auth = useAuth() + const isTeacher = useIsTeacher() + const navigate = useNavigate() + const modal = createCourseModal() const items: MenuProps["items"] = [ { @@ -33,15 +42,23 @@ const AuthNav = () => { { key: Themes.DARK, label: t("nav.dark"), - } - ] - }, - { - key: "logout", - label: t("nav.logout"), - icon: , + }, + ], }, ] + if (isTeacher) { + items.push({ + key: "createCourse", + label: t("home.createCourse"), + icon: , + }) + } + + items.push({ + key: "logout", + label: t("nav.logout"), + icon: , + }) const handleDropdownClick: MenuProps["onClick"] = (menu) => { switch (menu.key) { @@ -49,17 +66,18 @@ const AuthNav = () => { navigate(AppRoutes.PROFILE) break case "logout": - msalInstance.logoutPopup({ - account: auth, - }) + auth.logout() + window.location.replace(BACKEND_SERVER + "/web/auth/signout") break case Themes.DARK: case Themes.LIGHT: app.setTheme(menu.key as Themes) break + case "createCourse": + modal.showModal() } } - + return (<>
{ }} > - {auth!.name} + + {auth!.account?.name} +
- ) } diff --git a/frontend/src/components/layout/nav/Layout.tsx b/frontend/src/components/layout/nav/Layout.tsx index 2e1ed3a2..4b6afcdc 100644 --- a/frontend/src/components/layout/nav/Layout.tsx +++ b/frontend/src/components/layout/nav/Layout.tsx @@ -1,4 +1,4 @@ -import { AuthenticatedTemplate, useIsAuthenticated } from "@azure/msal-react" + import AuthNav from "./AuthNav" import { FC, PropsWithChildren } from "react" import { Layout as AntLayout, Flex } from "antd" @@ -6,29 +6,24 @@ import Logo from "../../Logo" import Sidebar from "../sidebar/Sidebar" import LanguageDropdown from "../../LanguageDropdown" - +import useAuth from "../../../hooks/useAuth"; const Layout: FC> = ({ children }) => { - const isAuthenticated = useIsAuthenticated() + const auth = useAuth() - if(!isAuthenticated) return <>{children} + if(!auth.isAuthenticated) return <>{children} return (
- {/* */} - - - - - + diff --git a/frontend/src/components/layout/nav/UnauthNav.tsx b/frontend/src/components/layout/nav/UnauthNav.tsx index 78529094..7066c747 100644 --- a/frontend/src/components/layout/nav/UnauthNav.tsx +++ b/frontend/src/components/layout/nav/UnauthNav.tsx @@ -1,15 +1,17 @@ import { useTranslation } from "react-i18next"; -import { msalInstance } from "../../../index" import { Button } from "antd"; +import useAuth from "../../../hooks/useAuth"; +import {useNavigate} from "react-router-dom"; +import {BACKEND_SERVER} from "../../../util/backendServer"; const UnauthNav = () => { const { t } = useTranslation(); + const auth = useAuth(); + const navigate = useNavigate(); const handleLogin = async () => { try { - await msalInstance.loginPopup({ - scopes: ['openid', 'profile', 'User.Read'], - }); - + await auth.login() + window.location.replace(BACKEND_SERVER + "/web/auth/signin") } catch (error) { console.error(error) } diff --git a/frontend/src/components/layout/sidebar/Sidebar.tsx b/frontend/src/components/layout/sidebar/Sidebar.tsx index bacb262a..ce770a47 100644 --- a/frontend/src/components/layout/sidebar/Sidebar.tsx +++ b/frontend/src/components/layout/sidebar/Sidebar.tsx @@ -63,19 +63,21 @@ const Sidebar: FC = () => { + } diff --git a/frontend/src/components/other/GroupMembersTransfer.tsx b/frontend/src/components/other/GroupMembersTransfer.tsx index b6fac421..3a81ea13 100644 --- a/frontend/src/components/other/GroupMembersTransfer.tsx +++ b/frontend/src/components/other/GroupMembersTransfer.tsx @@ -1,12 +1,12 @@ import { FC, useEffect, useMemo, useState } from "react" import { GroupType } from "../../pages/project/components/GroupTab" -import { Alert, Button, Select, Space, Switch, Table, Transfer } from "antd" +import { Alert, Button, Select, Table, Transfer } from "antd" import type { GetProp, SelectProps, TableColumnsType, TableProps, TransferProps } from "antd" -import apiCall from "../../util/apiFetch" import { ApiRoutes } from "../../@types/requests.d" import { CourseMemberType } from "../../pages/course/components/membersTab/MemberCard" import { useTranslation } from "react-i18next" +import useApi from "../../hooks/useApi" type TransferItem = GetProp[number] type TableRowSelection = TableProps["rowSelection"] @@ -57,16 +57,28 @@ const TableTransfer = ({ leftColumns, rightColumns, emptyText, ...restProps }: T ) -const GroupMembersTransfer: FC<{ groups: GroupType[]; onChanged: () => void; courseId: number | string }> = ({ groups, onChanged, courseId }) => { - const [targetKeys, setTargetKeys] = useState>({}) +export type GroupMembers = Record + +const GroupMembersTransfer: FC<{ value?: GroupMembers,groups: GroupType[]; onChange?: (newTargetKeys:GroupMembers) => void; courseId: number | string }> = ({ groups, onChange, courseId, value, ...args }) => { const [courseMembers, setCourseMembers] = useState(null) const [selectedGroup, setSelectedGroup] = useState(null) const { t } = useTranslation() - + const API = useApi() + console.log(courseMembers, selectedGroup, groups, value); useEffect(()=> { if(courseMembers === null || !groups?.length) return + + + let groupsMembers:GroupMembers = {} + for( let group of groups) { + groupsMembers[group.name] = group.members.map((m) => m.userId) + } + if(onChange) onChange(groupsMembers) + setSelectedGroup(groups[0]) + + },[groups, courseMembers]) @@ -75,14 +87,17 @@ const GroupMembersTransfer: FC<{ groups: GroupType[]; onChanged: () => void; cou }, [courseId]) const fetchCourseMembers = async () => { - const response = await apiCall.get(ApiRoutes.COURSE_MEMBERS, { courseId }) - setCourseMembers(response.data.filter(m => m.relation === "enrolled")) + const response = await API.GET(ApiRoutes.COURSE_MEMBERS, { pathValues: { courseId } },"message") + if(!response.success) return + + setCourseMembers(response.response.data.filter(m => m.relation === "enrolled")) } - const onChange: TableTransferProps["onChange"] = (nextTargetKeys) => { + const onChangeHandler: TableTransferProps["onChange"] = (nextTargetKeys) => { if (!selectedGroup) return console.error("No group selected") - setTargetKeys((curr) => ({ ...curr, [selectedGroup?.groupId]: nextTargetKeys })) - // TODO: make api call here or when pressing save + const newTargetKeys = { ...value, [selectedGroup?.name]: nextTargetKeys as any as number[]} + // setTargetKeys(newTargetKeys) + if(onChange) onChange(newTargetKeys) } const columns: TableColumnsType = [ @@ -99,8 +114,8 @@ const GroupMembersTransfer: FC<{ groups: GroupType[]; onChanged: () => void; cou }, ] - const changeGroup: SelectProps["onChange"] = (e: number) => { - const group = groups.find((g) => g.groupId === e) + const changeGroup: SelectProps["onChange"] = (e: string) => { + const group = groups.find((g) => g.name === e) if (group == null) return console.error("Group not found: " + e) setSelectedGroup(group) } @@ -110,7 +125,7 @@ const GroupMembersTransfer: FC<{ groups: GroupType[]; onChanged: () => void; cou if(!courseMembers) { return } - let randomGroups: Record = {} + let randomGroups: Record = {} let members = [...courseMembers] members = members.sort(() => Math.random() - 0.5) @@ -118,10 +133,11 @@ const GroupMembersTransfer: FC<{ groups: GroupType[]; onChanged: () => void; cou const group = groups[i] const groupMembers = members.splice(0, group.capacity) // @ts-ignore //TODO: fix the types so i can remove the ts ignore - randomGroups[group.groupId] = groupMembers.map((m) => m.user.userId) + randomGroups[group.name] = groupMembers.map((m) => m.user.userId) } console.log(randomGroups); - setTargetKeys(randomGroups) + // setTargetKeys(randomGroups) + if(onChange) onChange(randomGroups) } const renderFooter: TransferProps["footer"] = (_, info) => { @@ -134,13 +150,13 @@ const GroupMembersTransfer: FC<{ groups: GroupType[]; onChanged: () => void; cou info?.direction === "left" ? : + + + } + /> + {course.joinKey && ( + + +
+ + ) } export default CourseInvite diff --git a/frontend/src/pages/editProject/EditProject.tsx b/frontend/src/pages/editProject/EditProject.tsx index 4ec940d9..ef7c6b62 100644 --- a/frontend/src/pages/editProject/EditProject.tsx +++ b/frontend/src/pages/editProject/EditProject.tsx @@ -1,6 +1,6 @@ import React, { useContext, useEffect, useState } from "react" -import { useParams, useNavigate } from "react-router-dom" -import { Button, Form, Card } from "antd" +import { useParams, useNavigate, useLocation } from "react-router-dom" +import { Button, Form, UploadProps } from "antd" import { useTranslation } from "react-i18next" import ProjectForm from "../../components/forms/ProjectForm" import { EditFilled } from "@ant-design/icons" @@ -15,130 +15,159 @@ import useApi from "../../hooks/useApi" import saveDockerForm, { DockerFormData } from "../../components/common/saveDockerForm" const EditProject: React.FC = () => { - const [form] = Form.useForm() - const { t } = useTranslation() - const { courseId, projectId } = useParams() - const [loading, setLoading] = useState(false) - const API = useApi() - const [error, setError] = useState(null) // Gebruik ProjectError type voor error state - const navigate = useNavigate() - const project = useProject() - const { updateProject } = useContext(ProjectContext) - const [initialDockerValues, setInitialDockerValues] = useState(null) - - const updateDockerForm = async () => { - if (!projectId) return - const response = await API.GET(ApiRoutes.PROJECT_TESTS, { pathValues: { id: projectId } }) - if(!response.success) return setInitialDockerValues(null) - - let formVals:POST_Requests[ApiRoutes.PROJECT_TESTS] ={ - structureTest: null, - dockerTemplate: null, - dockerScript: null, - dockerImage: null, - } - if (response.success) { - const tests = response.response.data - console.log(tests); - formVals = { - structureTest: tests.structureTest ?? "", - dockerTemplate: tests.dockerTemplate ?? "", - dockerScript: tests.dockerScript ?? "", - dockerImage: tests.dockerImage ?? "", - } + const [form] = Form.useForm() + const { t } = useTranslation() + const { courseId, projectId } = useParams() + const [loading, setLoading] = useState(false) + const API = useApi() + const [error, setError] = useState(null) + const navigate = useNavigate() + const project = useProject() + const { updateProject } = useContext(ProjectContext) + const [initialDockerValues, setInitialDockerValues] = useState(null) + const location = useLocation() + + const updateDockerForm = async () => { + if (!projectId) return + const response = await API.GET(ApiRoutes.PROJECT_TESTS, { pathValues: { id: projectId } }) + if (!response.success) return setInitialDockerValues(null) + + let formVals: POST_Requests[ApiRoutes.PROJECT_TESTS] = { + structureTest: null, + dockerTemplate: null, + dockerScript: null, + dockerImage: null, + } + if (response.success) { + const tests = response.response.data + console.log(tests) + + if (tests.extraFilesName) { + const downloadLink = AppRoutes.DOWNLOAD_PROJECT_TESTS.replace(":projectId", projectId).replace(":courseId", courseId!) + + const uploadVal: UploadProps["defaultFileList"] = [{ + uid: '1', + name: tests.extraFilesName, + status: 'done', + url: downloadLink, + type: "file", + }] + + form.setFieldValue("dockerTestDir", uploadVal) + } + + formVals = { + structureTest: tests.structureTest ?? "", + dockerTemplate: tests.dockerTemplate ?? "", + dockerScript: tests.dockerScript ?? "", + dockerImage: tests.dockerImage ?? "", + } + } + + form.setFieldsValue(formVals) + + setInitialDockerValues(formVals) } - form.setFieldsValue(formVals) + console.log(initialDockerValues) - setInitialDockerValues(formVals) - } + useEffect(() => { + if (!project) return - console.log(initialDockerValues); + updateDockerForm() + }, [project?.projectId]) - useEffect(() => { - if (!project) return + const handleCreation = async () => { + const values: ProjectFormData & DockerFormData = form.getFieldsValue() + if (values.visible) { + values.visibleAfter = null + } - updateDockerForm() - }, [project]) + console.log(values) - const handleCreation = async () => { - const values: ProjectFormData & DockerFormData = form.getFieldsValue() - console.log(values) + if (!courseId || !projectId) return console.error("courseId or projectId is undefined") + setLoading(true) - if (!courseId || !projectId) return console.error("courseId or projectId is undefined") - setLoading(true) + const response = await API.PUT( + ApiRoutes.PROJECT, + { + body: values, + pathValues: { id: projectId }, + }, + "alert" + ) + if (!response.success) { + setError(response.alert || null) + setLoading(false) + return + } - const response = await API.PUT( - ApiRoutes.PROJECT, - { - body: values, - pathValues: { id: projectId }, - }, - "alert" - ) + let promises = [] + + promises.push(saveDockerForm(form, initialDockerValues, API, projectId)) + + if (form.isFieldTouched("groups") && values.groupClusterId && values.groups) { + promises.push(API.PUT(ApiRoutes.CLUSTER_FILL, { body: values.groups, pathValues: { id: values.groupClusterId } }, "message")) + } - await saveDockerForm(form, initialDockerValues, API, projectId) + await Promise.all(promises) - if (!response.success) { - setError(response.alert || null) - setLoading(false) - return + const result = response.response.data + updateProject(result) + navigate(AppRoutes.PROJECT.replace(":projectId", result.projectId.toString()).replace(":courseId", courseId)) // Navigeer naar het nieuwe project } - const result = response.response.data - updateProject(result) - navigate(AppRoutes.PROJECT.replace(":projectId", result.projectId.toString()).replace(":courseId", courseId)) // Navigeer naar het nieuwe project - } - - const onInvalid: FormProps["onFinishFailed"] = (e) => { - const errField = e.errorFields[0].name[0] - if (errField === "groupClusterId") navigate("#groups") - else if (errField === "structureTest") navigate("#structure") - else if (errField === "dockerScript" || errField === "dockerImage" || errField === "dockerTemplate") navigate("#tests") - else navigate("#general") - } - - if (!project) return <> - return ( - <> -
-
- - - - ), - }} - /> -
-
- - ) + + const onInvalid: FormProps["onFinishFailed"] = (e) => { + const errField = e.errorFields[0].name[0] + if (errField === "groupClusterId") navigate("#groups") + else if (errField === "structureTest") navigate("#structure") + else if (errField === "dockerScript" || errField === "dockerImage" || errField === "dockerTemplate") navigate("#tests") + else navigate("#general") + } + + if (!project) return <> + return ( + <> +
+
+ + + + ), + }} + /> +
+
+ + ) } export default EditProject diff --git a/frontend/src/pages/editProject/extrafilesDownload/ExtaFilesDownload.tsx b/frontend/src/pages/editProject/extrafilesDownload/ExtaFilesDownload.tsx new file mode 100644 index 00000000..e87ec940 --- /dev/null +++ b/frontend/src/pages/editProject/extrafilesDownload/ExtaFilesDownload.tsx @@ -0,0 +1,47 @@ +import Typography from "antd/es/typography/Typography" +import { useEffect, useLayoutEffect } from "react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" +import useApi from "../../../hooks/useApi" +import { ApiRoutes } from "../../../@types/requests.d" + +const ExtraFilesDownload = () => { + const { t } = useTranslation() + const { projectId } = useParams() + const API = useApi() + + useLayoutEffect(() => { + if (!projectId) return console.error("No projectId found") + const downloadFile = async () => { + try { + const res = await API.GET(ApiRoutes.PROJECT_TESTS_UPLOAD, { pathValues: { id: projectId }, config: { responseType: "blob" } }, "page") + if (!res.success) { + return + } + const res2 = await API.GET(ApiRoutes.PROJECT_TESTS, { pathValues: { id: projectId } }) + if (!res2.success) return + const filename = res2.response.data.extraFilesName + const response = res.response + const url = window.URL.createObjectURL(response.data) + const link = document.createElement("a") + link.href = url + link.setAttribute("download", filename) // or any other extension + document.body.appendChild(link) + link.click() + link.parentNode!.removeChild(link) + } catch (err) { + console.error(err) + } + } + + downloadFile() + }, [projectId]) + + return ( +
+ {t("project.downloadingFile")} +
+ ) +} + +export default ExtraFilesDownload diff --git a/frontend/src/pages/editRole/EditRole.tsx b/frontend/src/pages/editRole/EditRole.tsx index 0dc4713c..3e215c5f 100644 --- a/frontend/src/pages/editRole/EditRole.tsx +++ b/frontend/src/pages/editRole/EditRole.tsx @@ -1,81 +1,133 @@ -import { useEffect, useState } from "react"; -import { Spin } from "antd"; +import { useEffect, useState, useRef } from "react" +import { Row, Col, Form, Input, Button, Spin, Select, Typography } from "antd" import UserList from "./components/UserList" -import { ApiRoutes, GET_Responses, UserRole } from "../../@types/requests.d"; -import apiCall from "../../util/apiFetch"; +import { ApiRoutes, GET_Responses, UserRole } from "../../@types/requests.d" +import apiCall from "../../util/apiFetch" +import { useTranslation } from "react-i18next" +import { UsersListItem } from "./components/UserList" +import { useDebounceValue } from "usehooks-ts" +import { User } from "../../providers/UserProvider" export type UsersType = GET_Responses[ApiRoutes.USERS] - +type SearchType = "name" | "surname" | "email" const ProfileContent = () => { - const [users, setUsers] = useState(null); + const [users, setUsers] = useState(null) + + const [loading, setLoading] = useState(false) + const [form] = Form.useForm() + const searchValue = Form.useWatch("search", form) + const [debouncedSearchValue] = useDebounceValue(searchValue, 250) + const [searchType, setSearchType] = useState("name") + + const { t } = useTranslation() + + useEffect(() => { + onSearch() + }, [debouncedSearchValue]) - function updateRole(user: UsersType, role: UserRole) { - //TODO: PUT of PATCH call - console.log("User: ", user); - console.log("Role: ", role); - if(!users) return; - const updatedUsers = users.map((u) => { - if (u.userId === user.userId) { - return { ...u, role: role }; + function updateRole(user: UsersListItem, role: UserRole) { + console.log(user, role) + apiCall.patch(ApiRoutes.USER, { role: role }, { id: user.id }).then((res) => { + console.log(res.data) + //onSearch(); + //replace this user in the userlist with the updated one from res.data + const updatedUsers = users?.map((u) => { + if (u.id === user.id) { + return { ...u, role: res.data.role }; } return u; }); - setUsers(updatedUsers); - } + setUsers(updatedUsers?updatedUsers:null); + }) + } - useEffect(() => { - //TODO: moet met GET call - /*apiCall.get(ApiRoutes.USERS).then((res) => { - console.log(res.data) - setUsers(res.data) - })*/ - setUsers([ - { - userId: 1, - name: "Alice Kornelis", - role: "student", - email: "test@test.test", - url: "test" - }, - { - userId: 2, - name: "Bob Kornelis", - role: "teacher", - email: "test@test.test", - url: "test" - }, - { - userId: 3, - name: "Charlie Kornelis", - role: "admin", - email: "test@test.test", - url: "test" - } - ]); - }, []); + const onSearch = async () => { + const value = form.getFieldValue("search") + if (!value || value.length < 3) return + setLoading(true) + const params = new URLSearchParams() + params.append(searchType, form.getFieldValue("search")) + console.log(ApiRoutes.USERS + "?" + params.toString()) + apiCall.get((ApiRoutes.USERS + "?" + params.toString()) as ApiRoutes.USERS).then((res) => { + //FIXME: It's possible that request doesn't come in the same order as they're sent in. So it's possible that it would show the request of an old query + console.log(res.data) + setUsers(res.data) + setLoading(false) + }) + } + + return ( +
+
+ + { + if (searchType === "email") { + // Validate email + const emailRegex = /^[\w-]+(\.[\w-]+)*@([\w-]+\.)+[a-zA-Z]{2,7}$/ + if (!emailRegex.test(value)) { + return Promise.reject(new Error(t("editRole.invalidEmail"))) + } + } + // Validate name and surname + - if (users === null) { - return ( -
- + setSearchType(value)} + style={{ width: 120 }} + options={[ + { label: t("editRole.email"), value: "email" }, + { label: t("editRole.name"), value: "name" }, + { label: t("editRole.surname"), value: "surname" }, + ]} + /> + } + /> + + + {users !== null ? ( + <> + {loading ? ( +
+ +
+ ) : ( + -
- ) - } - - return ( -
- -
- ); -}; + )} + + ): {t("editRole.searchTutorial")}} +
+ ) +} export function EditRole() { - return ( - - ) -}; + return +} -export default EditRole; \ No newline at end of file +export default EditRole diff --git a/frontend/src/pages/editRole/components/UserList.tsx b/frontend/src/pages/editRole/components/UserList.tsx index eac1c14d..ed4b77bf 100644 --- a/frontend/src/pages/editRole/components/UserList.tsx +++ b/frontend/src/pages/editRole/components/UserList.tsx @@ -4,14 +4,20 @@ import { useTranslation } from "react-i18next" import { UserRole } from "../../../@types/requests" import { useState } from "react" import { UsersType } from "../EditRole" +import { GET_Responses, ApiRoutes } from "../../../@types/requests.d" +import { User } from "../../../providers/UserProvider" -const UserList: React.FC<{ users: UsersType[]; updateRole: (user: UsersType, role: UserRole) => void }> = ({ users, updateRole }) => { +//this is ugly, but if I put this in GET_responses, it will be confused with the User type (and there's no GET request with this as a response). +//this is also the only place this is used, so I think it's fine. +export type UsersListItem = { name: string, surname: string, id: number, url: string, email: string, role: UserRole } + +const UserList: React.FC<{ users: UsersType; updateRole: (user: UsersListItem, role: UserRole) => void }> = ({ users, updateRole }) => { const { t } = useTranslation() const [visible, setVisible] = useState(false) - const [selectedUser, setSelectedUser] = useState(null) + const [selectedUser, setSelectedUser] = useState(null) const [selectedRole, setSelectedRole] = useState(null) - const handleMenuClick = (user: UsersType, role: UserRole) => { + const handleMenuClick = (user: UsersListItem, role: UserRole) => { setSelectedUser(user) setSelectedRole(role) setVisible(true) @@ -27,9 +33,20 @@ const UserList: React.FC<{ users: UsersType[]; updateRole: (user: UsersType, rol setVisible(false) } - const renderUserItem = (user: UsersType) => ( + //sort based on name, then surname, then email + const sortedUsers = [...users].sort((a, b) => { + const nameComparison = a.name.localeCompare(b.name); + if (nameComparison !== 0) return nameComparison; + + const surnameComparison = a.surname.localeCompare(b.surname); + if (surnameComparison !== 0) return surnameComparison; + + return a.email.localeCompare(b.email); + }); + + const renderUserItem = (user: UsersListItem) => ( - + handleMenuClick(user, e.key as UserRole), + onClick: (e) => handleMenuClick(user, e.key as UserRole), }} > e.preventDefault()}> @@ -66,8 +83,9 @@ const UserList: React.FC<{ users: UsersType[]; updateRole: (user: UsersType, rol
- {t("editRole.confirmationText",{role: selectedRole, name: selectedUser?.name })} + {t("editRole.confirmationText",{role: selectedRole, name: selectedUser?.name + " " + selectedUser?.surname})}
diff --git a/frontend/src/pages/index/Home.tsx b/frontend/src/pages/index/Home.tsx index 22a670ba..986922c0 100644 --- a/frontend/src/pages/index/Home.tsx +++ b/frontend/src/pages/index/Home.tsx @@ -2,7 +2,6 @@ import { Card, Segmented, Typography } from "antd" import { useTranslation } from "react-i18next" import CreateCourseModal from "./components/CreateCourseModal" import { useEffect, useState } from "react" -import apiCall from "../../util/apiFetch" import { ApiRoutes, GET_Responses } from "../../@types/requests.d" import ProjectTable from "./components/ProjectTable" import ProjectTimeline from "../../components/other/ProjectTimeline" @@ -10,6 +9,8 @@ import { useLocalStorage } from "usehooks-ts" import { CalendarOutlined, NodeIndexOutlined, OrderedListOutlined, UnorderedListOutlined } from "@ant-design/icons" import ProjectCalander from "../../components/other/ProjectCalander" import CourseSection from "./components/CourseSection" +import useApi from "../../hooks/useApi" +import createCourseModal from "./components/CreateCourseModal" export type ProjectsType = GET_Responses[ApiRoutes.COURSE_PROJECTS] @@ -18,15 +19,22 @@ type ProjectView = "table" | "timeline" | "calendar" const Home = () => { const { t } = useTranslation() const [projects, setProjects] = useLocalStorage("__projects_cache",null) - const [open, setOpen] = useState(false) const [projectsViewMode, setProjectsViewMode] = useLocalStorage("projects_view", "table") + const API = useApi() + const courseModal = createCourseModal() useEffect(() => { - apiCall.get(ApiRoutes.PROJECTS).then((res) => { - const projects: ProjectsType = [...res.data.adminProjects, ...res.data.enrolledProjects.map((p) => ({ ...p.project, status: p.status }))] - console.log("=>", projects) + let ignore= false + + API.GET(ApiRoutes.PROJECTS, {}).then((res) => { + if(!res.success || ignore) return + const projects: ProjectsType = [...res.response.data.adminProjects, ...res.response.data.enrolledProjects.map((p) => ({ ...p.project, status: p.status }))] setProjects(projects) }) + + return () => { + ignore = true + } }, []) return ( @@ -34,7 +42,7 @@ const Home = () => {
setOpen(true)} + onOpenNew={() => courseModal.showModal()} />

@@ -93,10 +101,7 @@ const Home = () => {

- + ) } diff --git a/frontend/src/pages/index/HomeAuthCheck.tsx b/frontend/src/pages/index/HomeAuthCheck.tsx index ed2c172e..125d993a 100644 --- a/frontend/src/pages/index/HomeAuthCheck.tsx +++ b/frontend/src/pages/index/HomeAuthCheck.tsx @@ -1,16 +1,13 @@ -import { useIsAuthenticated, useMsal } from "@azure/msal-react" import Home from "./Home" import LandingPage from "./landing/LandingPage" +import useAuth from "../../hooks/useAuth"; const HomeAuthCheck = () => { - const isAuthenticated = useIsAuthenticated() - const { inProgress } = useMsal() - -if(inProgress === "startup") return null - if (isAuthenticated) { - return + const auth = useAuth() + if (auth.isAuthenticated) { + return } - return + return } export default HomeAuthCheck diff --git a/frontend/src/pages/index/components/CourseSection.tsx b/frontend/src/pages/index/components/CourseSection.tsx index 3bc38a5f..f503b892 100644 --- a/frontend/src/pages/index/components/CourseSection.tsx +++ b/frontend/src/pages/index/components/CourseSection.tsx @@ -1,14 +1,9 @@ -import { Button, Select, Space, Typography } from "antd" +import { Select, Typography } from "antd" import useUser from "../../../hooks/useUser" -import CourseCard from "./CourseCard" import { FC, useEffect, useMemo, useState } from "react" import { ApiRoutes, GET_Responses } from "../../../@types/requests.d" import { useTranslation } from "react-i18next" -import { PlusOutlined, RightOutlined } from "@ant-design/icons" import { ProjectsType } from "../Home" -import TeacherView from "../../../hooks/TeacherView" -import { useNavigate } from "react-router-dom" -import { AppRoutes } from "../../../@types/routes" import HorizontalCourseScroll from "./HorizontalCourseScroll" const { Option } = Select @@ -72,6 +67,8 @@ const CourseSection: FC<{ projects: ProjectsType | null; onOpenNew: () => void } return () => (ignore = true) }, [courses, projects]) + console.log(courseProjects); + const [filteredCourseProjects, filteredAdminCourseProjects, courseProjectsList, adminCourseProjectsList, yearOptions]: [CourseProjectList, CourseProjectList, CourseProjectList, CourseProjectList, number[] | null] = useMemo(() => { // Filter courses based on selected year if (courseProjects === null || adminCourseProjects === null) return [null, null, [], [], null] diff --git a/frontend/src/pages/index/components/CreateCourseModal.tsx b/frontend/src/pages/index/components/CreateCourseModal.tsx index 9e4ae652..f11aec97 100644 --- a/frontend/src/pages/index/components/CreateCourseModal.tsx +++ b/frontend/src/pages/index/components/CreateCourseModal.tsx @@ -1,69 +1,71 @@ -import { Alert, Form, Modal } from "antd" -import { FC, useEffect, useState } from "react" +import { Alert, Form } from "antd" +import { useEffect, useState } from "react" import { useTranslation } from "react-i18next" import CourseForm from "../../../components/forms/CourseForm" -import apiCall from "../../../util/apiFetch" import { ApiRoutes } from "../../../@types/requests.d" import useAppApi from "../../../hooks/useAppApi" -import axios, { AxiosError } from "axios" import { useNavigate } from "react-router-dom" import { AppRoutes } from "../../../@types/routes" import useUser from "../../../hooks/useUser" +import useApi from "../../../hooks/useApi" -const CreateCourseModal: FC<{ open: boolean,setOpen:(b:boolean)=>void }> = ({ open,setOpen }) => { +const createCourseModal = () => { const { t } = useTranslation() const [form] = Form.useForm() const [error, setError] = useState(null) - const [loading,setLoading] = useState(false) - const {message} = useAppApi() + const { message, modal } = useAppApi() const navigate = useNavigate() - const {updateCourses} = useUser() + const { updateCourses } = useUser() + const API = useApi() + useEffect(() => { + form.setFieldValue("year", new Date().getFullYear() - 1) + }, []) - useEffect(()=> { - form.setFieldValue("year", new Date().getFullYear()-1) - },[]) + const onFinish = () => { + return new Promise(async (resolve, reject) => { + await form.validateFields() + setError(null) - const onFinish = async () => { - await form.validateFields() - setError(null) - - const values:{name:string, description:string} = form.getFieldsValue() - console.log(values); - values.description ??= "" - setLoading(true) - try { - const course = await apiCall.post(ApiRoutes.COURSES, values) + const values: { name: string; description: string } = form.getFieldsValue() + console.log(values) + values.description ??= "" + const res = await API.POST(ApiRoutes.COURSES, { body: values }, "message") + if (!res.success) return reject() + const course = res.response message.success(t("home.courseCreated")) await updateCourses() navigate(AppRoutes.COURSE.replace(":courseId", course.data.courseId.toString())) - } catch(err){ - console.error(err); - if(axios.isAxiosError(err)){ - setError(err.response?.data.message || t("woops")) - } else { - message.error(t("woops")) - } - } finally { - setLoading(false) - } + resolve() + }) } - return ( - setOpen(false)} - onOk={onFinish} - okText={t("course.createCourse")} - okButtonProps={{ loading }} - cancelText={t("cancel")} - - > - {error && } - - - ) + return { + showModal: () => { + modal.info({ + title: t("home.createCourse"), + width: 500, + className: "modal-no-icon", + onOk: onFinish, + okText: t("course.createCourse"), + cancelText: t("cancel"), + okCancel: true, + icon: null, + content: ( + <> + {error && ( + + )} + + + ), + }) + }, + } } -export default CreateCourseModal +export default createCourseModal diff --git a/frontend/src/pages/index/components/ProjectCard.tsx b/frontend/src/pages/index/components/ProjectCard.tsx index 980b0e91..e5244e3c 100644 --- a/frontend/src/pages/index/components/ProjectCard.tsx +++ b/frontend/src/pages/index/components/ProjectCard.tsx @@ -1,59 +1,72 @@ import { FC, useEffect, useState } from "react" -import ProjectTable, { ProjectType } from "./ProjectTable" +import ProjectTableCourse, { ProjectType } from "./ProjectTableCourse" +import ProjectTable, { ProjectType as NormalProjectType } from "./ProjectTable" import { Button, Card } from "antd" -import apiCall from "../../../util/apiFetch" import { ApiRoutes } from "../../../@types/requests.d" -import useIsTeacher from "../../../hooks/useIsTeacher" import { useTranslation } from "react-i18next" import { AppRoutes } from "../../../@types/routes" -import { Link, useNavigate } from "react-router-dom" +import { useNavigate } from "react-router-dom" import CourseAdminView from "../../../hooks/CourseAdminView" import { PlusOutlined } from "@ant-design/icons" +import useApi from "../../../hooks/useApi" +import useIsCourseAdmin from "../../../hooks/useIsCourseAdmin"; const ProjectCard: FC<{ courseId?: number }> = ({ courseId }) => { - const [projects, setProjects] = useState(null) - const { t } = useTranslation() - const navigate = useNavigate() + const [projects, setProjects] = useState(null) + const { t } = useTranslation() + const navigate = useNavigate() + const API = useApi() + const isCourseAdmin = useIsCourseAdmin() - useEffect(() => { - if (courseId) { - apiCall.get(ApiRoutes.COURSE_PROJECTS, { id: courseId }).then((res) => { - setProjects(res.data) - }) - } - }, [courseId]) + useEffect(() => { + if (courseId) { + API.GET(ApiRoutes.COURSE_PROJECTS, { pathValues: { id: courseId } }).then((res) => { + if (!res.success) return + setProjects(res.response.data) + }) + } + }, [courseId]) - return ( - <> - -
- -
-
- - - - - ) + return ( + <> + {isCourseAdmin && ( + +
+ +
+
+ )} + + {isCourseAdmin ? ( + + ) : ( + + )} + + + ) } export default ProjectCard diff --git a/frontend/src/pages/index/components/ProjectStatusTag.tsx b/frontend/src/pages/index/components/ProjectStatusTag.tsx index 6f1e0bcd..1baa0527 100644 --- a/frontend/src/pages/index/components/ProjectStatusTag.tsx +++ b/frontend/src/pages/index/components/ProjectStatusTag.tsx @@ -1,4 +1,4 @@ -import { CheckCircleOutlined, CloseCircleOutlined, MinusCircleOutlined } from "@ant-design/icons" +import { CheckCircleOutlined, CloseCircleOutlined, MinusCircleOutlined, UserOutlined } from "@ant-design/icons" import { Tag } from "antd" import { FC } from "react" import { useTranslation } from "react-i18next" @@ -15,6 +15,8 @@ const ProjectStatusTag: FC<{ status: ProjectStatus,icon?:boolean }> = ({ status, return } color="volcano">{t("home.projects.status.failed")} } else if (status === "not started") { return } color="default">{t("home.projects.status.notStarted")} + }else if (status === "no group") { + return } color="warning">{t("home.projects.status.noGroup")} } else return null } @@ -24,6 +26,8 @@ const ProjectStatusTag: FC<{ status: ProjectStatus,icon?:boolean }> = ({ status, return {t("home.projects.status.failed")} } else if (status === "not started") { return {t("home.projects.status.notStarted")} + }else if (status === "no group") { + return {t("home.projects.status.noGroup")} } else return null } diff --git a/frontend/src/pages/index/components/ProjectTableCourse.tsx b/frontend/src/pages/index/components/ProjectTableCourse.tsx new file mode 100644 index 00000000..d9ebaf0e --- /dev/null +++ b/frontend/src/pages/index/components/ProjectTableCourse.tsx @@ -0,0 +1,138 @@ +import { Button, Table, TableProps, Tag, Tooltip } from "antd" +import { FC, useMemo } from "react" +import { ApiRoutes, GET_Responses } from "../../../@types/requests.d" +import { useTranslation } from "react-i18next" +import i18n from 'i18next' +import useAppApi from "../../../hooks/useAppApi" +import ProjectStatusTag from "./ProjectStatusTag" +import GroupProgress from "./GroupProgress" +import { Link } from "react-router-dom" +import { AppRoutes } from "../../../@types/routes" +import { ClockCircleOutlined } from "@ant-design/icons" +import useIsCourseAdmin from "../../../hooks/useIsCourseAdmin"; + +export type ProjectType = GET_Responses[ApiRoutes.PROJECT] + +const ProjectTableCourse: FC<{ projects: ProjectType[] | null, ignoreColumns?: string[] }> = ({ projects, ignoreColumns }) => { + const { t } = useTranslation() + const { modal } = useAppApi() + const isCourseAdmin = useIsCourseAdmin() + + const columns: TableProps["columns"] = useMemo( + () => { + let columns: TableProps["columns"] = [ + { + title: t("home.projects.name"), + key: "name", + render: (project: ProjectType) => ( + + + + ) + }, + { + title: t("home.projects.course"), + dataIndex: "course", + key: "course", + sorter: (a: ProjectType, b: ProjectType) => a.course.name.localeCompare(b.course.name), + sortDirections: ['ascend', 'descend'], + render: (course: ProjectType["course"]) => course.name + }, + { + title: t("home.projects.deadline"), + dataIndex: "deadline", + key: "deadline", + sorter: (a: ProjectType, b: ProjectType) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime(), + sortDirections: ['ascend', "descend"], + defaultSortOrder: "ascend", + filters: [{ text: t('home.projects.deadlineNotPassed'), value: 'notPassed' }], + onFilter: (value: any, record: any) => { + const currentTimestamp = new Date().getTime(); + const deadlineTimestamp = new Date(record.deadline).getTime(); + return value === 'notPassed' ? deadlineTimestamp >= currentTimestamp : true; + }, + defaultFilteredValue: ["notPassed"], + render: (text: string) => + new Date(text).toLocaleString(i18n.language, { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }), + }, + { + title: t("home.projects.groupProgress"), + key: "progress", + render: (project: ProjectType) => ( + + ), + } + ] + + if (ignoreColumns) { + columns = columns.filter((c) => !ignoreColumns.includes(c.key as string)) + } + + if (isCourseAdmin) { + columns = columns.filter((c) => c.key !== "status") + columns.push({ + title: t("home.projects.visibility"), + key: "visible", + render: (project: ProjectType) => { + if (project.visible) { + return {t("home.projects.visibleStatus.visible")} + } else if (project.visibleAfter) { + return ( + + } color="default">{t("home.projects.visibleStatus.scheduled")} + + ) + } else { + return {t("home.projects.visibleStatus.invisible")} + } + } + }) + } else { + columns.push({ + title: t("home.projects.projectStatus"), + key: "status", + render: (project: ProjectType) => + project.status && , + }) + } + + return columns + }, + [t, modal, projects, isCourseAdmin] + ) + + return ( + project.projectId} + /> + ) +} + +export default ProjectTableCourse diff --git a/frontend/src/pages/index/landing/LandingPage.tsx b/frontend/src/pages/index/landing/LandingPage.tsx index 0e26fe30..d210000a 100644 --- a/frontend/src/pages/index/landing/LandingPage.tsx +++ b/frontend/src/pages/index/landing/LandingPage.tsx @@ -10,8 +10,10 @@ import jsLogo from "../../../assets/landingPageLogos/jsLogo.png" import dockerLogo from "../../../assets/landingPageLogos/dockerLogo.png" import codeLogo from "../../../assets/landingPageLogos/codeLogo.png" import { useTranslation } from "react-i18next" -import { msalInstance } from "../../.." import { motion } from "framer-motion" +import useAuth from "../../../hooks/useAuth"; +import {useNavigate} from "react-router-dom"; +import {BACKEND_SERVER} from "../../../util/backendServer"; const defaultTransition = { duration: 0.5, ease: [0.44, 0, 0.56, 1], type: "tween" } @@ -21,12 +23,12 @@ const defaultInitial = { opacity: 0.001, y: 64 } const LandingPage: FC = () => { const { t } = useTranslation() - + const auth = useAuth() + const navigate = useNavigate() const handleLogin = async () => { try { - await msalInstance.loginPopup({ - scopes: ["openid", "profile", "User.Read"], - }) + await auth.login() + window.location.replace(BACKEND_SERVER + "/web/auth/signin") } catch (error) { console.error(error) } diff --git a/frontend/src/pages/index/landing/Navbar.tsx b/frontend/src/pages/index/landing/Navbar.tsx index 091a7c61..e47bf2e0 100644 --- a/frontend/src/pages/index/landing/Navbar.tsx +++ b/frontend/src/pages/index/landing/Navbar.tsx @@ -21,6 +21,11 @@ const Navbar: FC<{ onLogin: () => void }> = ({ onLogin }) => {
+
+ window.open("https://github.com/SELab-2/UGent-6/wiki", "_blank")} style={{ cursor: "pointer" }} >{t("landingPage.docs")} + +
+
diff --git a/frontend/src/pages/profile/Profile.tsx b/frontend/src/pages/profile/Profile.tsx index d26ed512..ff794b58 100644 --- a/frontend/src/pages/profile/Profile.tsx +++ b/frontend/src/pages/profile/Profile.tsx @@ -11,7 +11,7 @@ const ProfileContent = () => { + >
) } diff --git a/frontend/src/pages/project/Project.tsx b/frontend/src/pages/project/Project.tsx index f17676cd..d18afdb0 100644 --- a/frontend/src/pages/project/Project.tsx +++ b/frontend/src/pages/project/Project.tsx @@ -1,4 +1,4 @@ -import { Button, Card, Tabs, TabsProps, Tooltip, theme } from "antd" +import { Button, Card, Popconfirm, Tabs, TabsProps, Tooltip, theme } from "antd" import { ApiRoutes, GET_Responses } from "../../@types/requests.d" import { useTranslation } from "react-i18next" import { Link, useLocation, useNavigate, useParams } from "react-router-dom" @@ -6,15 +6,14 @@ import SubmissionCard from "./components/SubmissionTab" import useCourse from "../../hooks/useCourse" import useProject from "../../hooks/useProject" import ScoreCard from "./components/ScoreTab" -import CourseAdminView from "../../hooks/CourseAdminView" -import { DeleteOutlined, DownloadOutlined, HeatMapOutlined, InfoCircleOutlined, PlusOutlined, SendOutlined, SettingFilled, TeamOutlined } from "@ant-design/icons" +import { DeleteOutlined, FileDoneOutlined, InfoCircleOutlined, PlusOutlined, SendOutlined, SettingFilled, TeamOutlined } from "@ant-design/icons" import { useMemo, useState } from "react" import useIsCourseAdmin from "../../hooks/useIsCourseAdmin" import GroupTab from "./components/GroupTab" import { AppRoutes } from "../../@types/routes" import SubmissionsTab from "./components/SubmissionsTab" import MarkdownTextfield from "../../components/input/MarkdownTextfield" -import apiCall from "../../util/apiFetch" +import useApi from "../../hooks/useApi" // dracula, darcula,oneDark,vscDarkPlus | prism, base16AteliersulphurpoolLight, oneLight @@ -30,6 +29,7 @@ const Project = () => { const navigate = useNavigate() const location = useLocation() const [activeTab, setActiveTab] = useState(location.hash.slice(1) || "description") + const API = useApi() const now = Date.now() const deadline = new Date(project?.deadline ?? "").getTime() @@ -60,23 +60,44 @@ const Project = () => { }) } - items.push({ - key: "submissions", - label: t("project.submissions"), - icon: , - children: courseAdmin ? ( - - - - ) : ( - - ), - }) + // if we work without groups -> always show submissions & score + // if we work with groups -> only show submissions if we are in a group + // if we are course admin -> always show submissions but not score + if((project?.groupId || !project?.clusterId) || courseAdmin) { + + items.push({ + key: "submissions", + label: t("project.submissions"), + icon: courseAdmin ? : , + children: courseAdmin ? ( + + + + ) : ( + + ), + }) + + if(courseAdmin) { + items.push({ + key: "testSubmissions", + label: t("project.testSubmissions"), + icon: , + children: + + }) + } - if (!courseAdmin) { + } + + if ((project?.groupId || !project?.clusterId) && !courseAdmin) { items.push({ key: "score", label: t("course.score"), @@ -98,8 +119,15 @@ const Project = () => { const deleteProject = async () => { if (!project || !course) return console.error("project is undefined") - await apiCall.delete(ApiRoutes.PROJECT, undefined, { id: project!.projectId + "" }) - + const res = await API.DELETE( + ApiRoutes.PROJECT, + { pathValues: { id: project.projectId } }, + { + mode: "message", + successMessage: t("project.successfullyDeleted"), + } + ) + if (!res.success) return navigate(AppRoutes.COURSE.replace(":courseId", course.courseId + "")) } @@ -124,7 +152,14 @@ const Project = () => { extra={ courseAdmin ? ( <> - + + - - - ) : null - } + /> diff --git a/frontend/src/pages/project/components/GroupTab.tsx b/frontend/src/pages/project/components/GroupTab.tsx index 24aa6f51..37aa6ae2 100644 --- a/frontend/src/pages/project/components/GroupTab.tsx +++ b/frontend/src/pages/project/components/GroupTab.tsx @@ -1,14 +1,19 @@ -import { FC, useEffect, useState } from "react" +import { FC, useContext, useEffect, useState } from "react" import { ApiRoutes, GET_Responses } from "../../../@types/requests.d" import GroupList from "../../course/components/groupTab/GroupList" -import apiCall from "../../../util/apiFetch" import { useParams } from "react-router-dom" +import useApi from "../../../hooks/useApi" +import useProject from "../../../hooks/useProject" +import { ProjectContext } from "../../../router/ProjectRoutes" export type GroupType = GET_Responses[ApiRoutes.PROJECT_GROUPS][number] const GroupTab: FC<{}> = () => { const [groups, setGroups] = useState(null) const { projectId } = useParams() + const project = useProject() + const { updateProject } = useContext(ProjectContext) + const API = useApi() useEffect(() => { fetchGroups() @@ -16,15 +21,26 @@ const GroupTab: FC<{}> = () => { const fetchGroups = async () => { if (!projectId) return console.error("No projectId found") - const res = await apiCall.get(ApiRoutes.PROJECT_GROUPS, { id: projectId }) - console.log(res.data) - setGroups(res.data) + const res = await API.GET(ApiRoutes.PROJECT_GROUPS, { pathValues: { id: projectId } }) + if (!res.success) return + console.log(res.response.data) + setGroups(res.response.data) + } + + const handleGroupIdChange = async (groupId: number | null) => { + if (!project) return console.error("No projectId found") + let newProject = { ...project } + newProject.groupId = groupId + updateProject(newProject) } return ( ) } diff --git a/frontend/src/pages/project/components/ScoreTab.tsx b/frontend/src/pages/project/components/ScoreTab.tsx index 83a742cc..593f4b9c 100644 --- a/frontend/src/pages/project/components/ScoreTab.tsx +++ b/frontend/src/pages/project/components/ScoreTab.tsx @@ -1,61 +1,67 @@ import { Card, Typography } from "antd" import { useEffect, useState } from "react" import { ApiRoutes, GET_Responses } from "../../../@types/requests.d" -import apiCall from "../../../util/apiFetch" import useProject from "../../../hooks/useProject" import { useParams } from "react-router-dom" import { useTranslation } from "react-i18next" +import useApi from "../../../hooks/useApi" export type ScoreType = GET_Responses[ApiRoutes.PROJECT_SCORE] const ScoreCard = () => { - /** * undefined -> loading * null -> no score available * ScoreType -> score available */ const [score, setScore] = useState(undefined) - const {projectId} = useParams() - const {t} = useTranslation() + const { projectId } = useParams() + const { t } = useTranslation() const project = useProject() + const API = useApi() - useEffect(() => { // /projects/{projectid}/groups/{groupid}/score - if(!projectId) return console.error("No project id") - if(project?.groupId === undefined) return setScore(null) // Means you aren't in a group yet - + if (!projectId) return console.error("No project id") + if (project?.groupId === undefined) return setScore(null) // Means you aren't in a group yet + if(!project.groupId) return console.error("No groupId found") let ignore = false - apiCall.get(ApiRoutes.PROJECT_SCORE, {id:projectId,groupId: project?.groupId!}).then((response)=> { - console.log(response.data); - if (ignore) return - setScore( response.data) + - }).catch(err => { + API.GET(ApiRoutes.PROJECT_SCORE, { pathValues: { id: projectId, groupId: project.groupId } }).then((res) => { if (ignore) return - console.log(err); - setScore(null) + if (!res.success) return setScore(null) + setScore(res.response.data) }) return () => { ignore = true } - }, []) + }, [project?.groupId]) // don't show the card if no score is available if (score === undefined) return null - if (score === null) return
- {t("project.noScore")} -
+ if (score === null) + return ( +
+ {t("project.noScore")} +
+ ) return ( {score.score} / {project.maxScore}]} - + extra={[ + project && !!project.maxScore && ( + + {score.score} / {project.maxScore} + + ), + ]} > - - {score.feedback?.length ? {score.feedback} : ({t("project.noFeedback")})} + {score.feedback?.length ? {score.feedback} : ({t("project.noFeedback")})} ) } diff --git a/frontend/src/pages/project/components/SubmissionStatusTag.tsx b/frontend/src/pages/project/components/SubmissionStatusTag.tsx index 7c3beefd..02ae8fff 100644 --- a/frontend/src/pages/project/components/SubmissionStatusTag.tsx +++ b/frontend/src/pages/project/components/SubmissionStatusTag.tsx @@ -2,23 +2,28 @@ import { Tag } from "antd" import { FC } from "react" import { useTranslation } from "react-i18next" import { ApiRoutes, GET_Responses } from "../../../@types/requests" +import { LoadingOutlined } from "@ant-design/icons" export enum SubmissionStatus { STRUCTURE_REJECTED = 0, DOCKER_REJECTED = 1<<1, NOT_SUBMITTED = 1<<2, - PASSED = 1<<3 + PASSED = 1<<3, + RUNNING = 1<<4 } export function createStatusBitVector(submission: GET_Responses[ApiRoutes.SUBMISSION] | null) { - + console.log(submission); if(submission === null) return SubmissionStatus.NOT_SUBMITTED let status = 0 + if(submission.dockerStatus === "running") { + status |= SubmissionStatus.RUNNING + } if(!submission.structureAccepted){ status |= SubmissionStatus.STRUCTURE_REJECTED } - if(!submission.dockerAccepted){ + if(!submission.dockerFeedback.allowed){ status |= SubmissionStatus.DOCKER_REJECTED } if(status === 0){ @@ -42,6 +47,10 @@ const SubmissionStatusTag:FC<{status:number}> = ({ status }) => { return ( {t("project.notSubmitted")} ) + } else if (status & SubmissionStatus.RUNNING) { + return ( + } >{t("project.running")} + ) } return {t("project.passed")} diff --git a/frontend/src/pages/project/components/SubmissionTab.tsx b/frontend/src/pages/project/components/SubmissionTab.tsx index 5c8607a6..1c8df176 100644 --- a/frontend/src/pages/project/components/SubmissionTab.tsx +++ b/frontend/src/pages/project/components/SubmissionTab.tsx @@ -1,32 +1,40 @@ import { FC, useEffect, useState } from "react" import SubmissionList from "./SubmissionList" -import apiCall from "../../../util/apiFetch" import { ApiRoutes, GET_Responses } from "../../../@types/requests.d" -import { useParams } from "react-router-dom" import useProject from "../../../hooks/useProject" +import useApi from "../../../hooks/useApi" export type GroupSubmissionType = GET_Responses[ApiRoutes.PROJECT_GROUP_SUBMISSIONS][number] -const SubmissionTab: FC<{ projectId: number; courseId: number }> = ({ projectId, courseId }) => { +const SubmissionTab: FC<{ projectId: number; courseId: number; testSubmissions?: boolean }> = ({ projectId, courseId, testSubmissions }) => { const [submissions, setSubmissions] = useState(null) const project = useProject() + const API = useApi() useEffect(() => { - - if(!project) return - console.log(project.submissionUrl); - apiCall.get(project.submissionUrl ).then((res) => { - - setSubmissions(res.data.sort((a, b) => b.submissionId - a.submissionId)) + if (!project) return + if (!project.submissionUrl) return setSubmissions([]) + if (!project.groupId && !testSubmissions) return console.error("No groupId found") + console.log(project) + let ignore = false + console.log("Sending request to: ", project.submissionUrl) + API.GET(testSubmissions ? ApiRoutes.PROJECT_TEST_SUBMISSIONS : ApiRoutes.PROJECT_GROUP_SUBMISSIONS, { pathValues: { projectId: project.projectId, groupId: project.groupId ?? "" } }).then((res) => { + console.log(res) + if (!res.success || ignore) return + setSubmissions(res.response.data.sort((a, b) => b.submissionId - a.submissionId)) }) + return () => { + ignore = true + } + }, [projectId, courseId, project?.groupId]) + return ( + <> + - }, [projectId,courseId]) - - - - return ( + + ) } diff --git a/frontend/src/pages/project/components/SubmissionsTab.tsx b/frontend/src/pages/project/components/SubmissionsTab.tsx index 4156e5ac..d2729b3d 100644 --- a/frontend/src/pages/project/components/SubmissionsTab.tsx +++ b/frontend/src/pages/project/components/SubmissionsTab.tsx @@ -1,8 +1,13 @@ import { useEffect, useState } from "react" import { ApiRoutes, GET_Responses } from "../../../@types/requests.d" import SubmissionsTable from "./SubmissionsTable" -import apiCall from "../../../util/apiFetch" import { useParams } from "react-router-dom" +import useApi from "../../../hooks/useApi" +import { exportSubmissionStatusToCSV, exportToUfora } from "./createCsv" +import { Button, Space, Switch } from "antd" +import { DownloadOutlined, ExportOutlined } from "@ant-design/icons" +import { useTranslation } from "react-i18next" +import useProject from "../../../hooks/useProject" export type ProjectSubmissionsType = GET_Responses[ApiRoutes.PROJECT_SUBMISSIONS][number] @@ -10,24 +15,97 @@ export type ProjectSubmissionsType = GET_Responses[ApiRoutes.PROJECT_SUBMISSIONS const SubmissionsTab = () => { const [submissions, setSubmissions] = useState(null) const { projectId } = useParams() - useEffect(() => { - // TODO: make request to /projects/{projectid}/submissions - if(!projectId) return - apiCall.get(ApiRoutes.PROJECT_SUBMISSIONS,{id:projectId}).then((res) => { - console.log(res.data) - setSubmissions(res.data) + const API = useApi() + const { t } = useTranslation() + const project = useProject() + const [withArtifacts, setWithArtifacts] = useState(true) + useEffect(() => { + if (!projectId) return + let ignore = false + API.GET(ApiRoutes.PROJECT_SUBMISSIONS, { pathValues: { id: projectId } }).then((res) => { + if (!res.success || ignore) return + console.log(res.response.data) + setSubmissions(res.response.data) }) - }, []) + return () => { + ignore = true + } + }, [projectId]) + + const handleDownloadSubmissions = async () => { + if (!project) return + const apiRoute = ApiRoutes.PROJECT_DOWNLOAD_ALL_SUBMISSIONS + "?artifacts=true" + const response = await API.GET( + apiRoute as ApiRoutes.PROJECT_DOWNLOAD_ALL_SUBMISSIONS, + { + config: { + responseType: "blob", + transformResponse: [(data) => data], + }, + pathValues: { id: project.projectId }, + }, + "message" + ) + if (!response.success) return + console.log(response) + const url = window.URL.createObjectURL(new Blob([response.response.data])) + const link = document.createElement("a") + link.href = url + const fileName = `${project.name}-submissions.zip` + link.setAttribute("download", fileName) + document.body.appendChild(link) + link.click() + link.parentNode!.removeChild(link) + } + + const handleExportToUfora = () => { + if (!submissions || !project) return + exportToUfora(submissions, project.maxScore ?? 0) + } - const handleDownloadSubmissions = () => { - // TODO: implement this! + const exportStatus = () => { + if (!submissions) return + exportSubmissionStatusToCSV(submissions) } return ( - <> - - + + + + + + + + + ) } diff --git a/frontend/src/pages/project/components/SubmissionsTable.tsx b/frontend/src/pages/project/components/SubmissionsTable.tsx index 99d284d6..5bca0673 100644 --- a/frontend/src/pages/project/components/SubmissionsTable.tsx +++ b/frontend/src/pages/project/components/SubmissionsTable.tsx @@ -1,4 +1,4 @@ -import { Button, Input, List, Table, Typography } from "antd" +import { Button, Input, List, Table, Tooltip, Typography } from "antd" import { FC, useMemo } from "react" import { ProjectSubmissionsType } from "./SubmissionsTab" import { TableProps } from "antd/lib" @@ -8,25 +8,43 @@ import useProject from "../../../hooks/useProject" import SubmissionStatusTag, { createStatusBitVector } from "./SubmissionStatusTag" import { Link, useParams } from "react-router-dom" import { AppRoutes } from "../../../@types/routes" -import apiCall from "../../../util/apiFetch" import { ApiRoutes, PUT_Requests } from "../../../@types/requests.d" import useAppApi from "../../../hooks/useAppApi" +import useApi from "../../../hooks/useApi" const GroupMember = ({ name }: ProjectSubmissionsType["group"]["members"][number]) => { return {name} } -const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onChange: (s: ProjectSubmissionsType[]) => void }> = ({ submissions, onChange }) => { +const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onChange: (s: ProjectSubmissionsType[]) => void, withArtifacts?:boolean }> = ({ submissions, onChange,withArtifacts }) => { const { t } = useTranslation() const project = useProject() const { courseId, projectId } = useParams() const { message } = useAppApi() - - const updateTable = async (groupId: number, feedback: Partial) => { + const API = useApi() + const updateTable = async (groupId: number, feedback: Partial, usePost: boolean) => { if (!projectId || submissions === null || !groupId) return console.error("No projectId or submissions or groupId found") - const response = await apiCall.patch(ApiRoutes.PROJECT_SCORE, feedback, { id: projectId, groupId }) - const data = response.data + let res + if (usePost) { + res = await API.POST( + ApiRoutes.PROJECT_SCORE, + { + body: { + score: 0, + feedback: "", + ...feedback, + }, + pathValues: { id: projectId, groupId }, + }, + "message" + ) + } else { + res = await API.PATCH(ApiRoutes.PROJECT_SCORE, { body: feedback, pathValues: { id: projectId, groupId } }, "message") + } + if (!res.success) return + + const data = res.response.data const newSubmissions: ProjectSubmissionsType[] = submissions.map((s) => { if (s.group.groupId !== groupId) return s @@ -51,15 +69,44 @@ const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onCha else score = parseFloat(scoreStr) if (isNaN(score as number)) score = null if (score !== null && score > project.maxScore) return message.error(t("project.scoreTooHigh")) - await updateTable(s.group.groupId, { score }) + await updateTable(s.group.groupId, { score }, s.feedback === null) } const updateFeedback = async (s: ProjectSubmissionsType, feedback: string) => { - await updateTable(s.group.groupId, { feedback }) + await updateTable(s.group.groupId, { feedback }, s.feedback === null) + } + + const downloadFile = async (route: ApiRoutes.SUBMISSION_FILE | ApiRoutes.SUBMISSION_ARTIFACT, filename: string) => { + const response = await API.GET( + route, + { + config: { + responseType: "blob", + transformResponse: [(data) => data], + }, + }, + "message" + ) + if (!response.success) return + console.log(response) + const url = window.URL.createObjectURL(new Blob([response.response.data])) + const link = document.createElement("a") + link.href = url + let fileName = filename+".zip" // default filename + link.setAttribute("download", fileName) + document.body.appendChild(link) + link.click() + link.parentNode!.removeChild(link) + } - const downloadFile = async (s: ProjectSubmissionsType) => { - // TODO: implement this + + const downloadSubmission = async (submission: ProjectSubmissionsType) => { + if (!submission.submission) return console.error("No submission found") + downloadFile(submission.submission.fileUrl, submission.group.name+".zip") + if(withArtifacts && submission.submission.artifactUrl) { + downloadFile(submission.submission.artifactUrl, submission.group.name+"-artifacts.zip") + } } const columns: TableProps["columns"] = useMemo(() => { @@ -76,7 +123,7 @@ const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onCha { title: t("project.submission"), key: "submissionId", - render: (s: ProjectSubmissionsType) => ( + render: (s: ProjectSubmissionsType) => s.submission ? ( - ), + ) : null, }, { title: t("project.status"), @@ -103,9 +150,9 @@ const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onCha render: (time: ProjectSubmissionsType["submission"]) => time?.submissionTime && {new Date(time.submissionTime).toLocaleString()}, sorter: (a: ProjectSubmissionsType, b: ProjectSubmissionsType) => { // Implement sorting logic for submissionTime column - const timeA: any = a.submission?.submissionTime || 0; - const timeB: any = b.submission?.submissionTime || 0; - return timeA - timeB; + const timeA: any = a.submission?.submissionTime || 0 + const timeB: any = b.submission?.submissionTime || 0 + return timeA - timeB }, }, ] @@ -116,10 +163,10 @@ const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onCha key: "score", render: (s: ProjectSubmissionsType) => ( updateScore(s, e), maxLength: 10 }} > - {s.feedback?.score ?? "-"} + {s.feedback?.score ?? t("project.noScoreLabel")} ), }) @@ -129,11 +176,14 @@ const SubmissionsTable: FC<{ submissions: ProjectSubmissionsType[] | null; onCha title: "Download", key: "download", render: (s: ProjectSubmissionsType) => ( -
updateFeedback(g, value), }} + type={g.feedback?.feedback ? undefined : "secondary"} > - {g.feedback?.feedback || "-"} + {g.feedback?.feedback || t('project.noFeedbackLabel')} diff --git a/frontend/src/pages/project/components/createCsv.ts b/frontend/src/pages/project/components/createCsv.ts new file mode 100644 index 00000000..1724abf6 --- /dev/null +++ b/frontend/src/pages/project/components/createCsv.ts @@ -0,0 +1,63 @@ +import { ProjectSubmissionsType } from "./SubmissionsTab" +import { saveAs } from "file-saver" +import { unparse } from "papaparse" + +function exportSubmissionStatusToCSV(submissions: ProjectSubmissionsType[]): void { + const csvData = submissions.map((submission) => { + const groupId = submission.group.groupId + const groupName = submission.group.name + let submissionTime = "Not submitted" + let structureStatus = "Not submitted" + let dockerStatus = "Not submitted" + if (submission.submission) { + submissionTime = submission.submission.submissionTime + structureStatus = submission.submission?.structureAccepted ? "Accepted" : "Rejected" + dockerStatus = submission.submission?.dockerStatus || "Unknown" + } + + const students = submission.group.members.map((member) => `${member.name} (${member.studentNumber ?? "N/A"})`).join("; ") + + return { + groupId, + groupName, + structureStatus, + dockerStatus, + submissionTime, + students, + } + }) + + const csvString = unparse(csvData) + + const blob = new Blob([csvString], { type: "text/csv;charset=utf-8;" }) + saveAs(blob, "project_submissions.csv") +} + + + +function exportToUfora(submissions: ProjectSubmissionsType[],maxScore:number): void { + + const evaluationHeader = `Evaluation 1 Exercise 1 Points Grade `; + + const csvData = submissions.flatMap(submission => + submission.group.members.map(member => ({ + OrgDefinedId: `#${member.studentNumber}`, + LastName: member.name.split(' ').slice(-1)[0], + FirstName: member.name.split(' ').slice(0, -1).join(' '), + Email: member.email, + [evaluationHeader]: submission.feedback?.score ?? "", + "End-of-Line Indicator": "#" + })) + ); + console.log(submissions, csvData); + + const csvString = unparse(csvData, { + quotes: true, + header: true + }); + + const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' }); + saveAs(blob, 'ufora_submissions.csv'); +} + +export { exportSubmissionStatusToCSV,exportToUfora } diff --git a/frontend/src/pages/projectCreate/ProjectCreate.tsx b/frontend/src/pages/projectCreate/ProjectCreate.tsx index d00940de..2abc3bea 100644 --- a/frontend/src/pages/projectCreate/ProjectCreate.tsx +++ b/frontend/src/pages/projectCreate/ProjectCreate.tsx @@ -3,7 +3,6 @@ import { useParams, useNavigate } from "react-router-dom" import { Button, Form } from "antd" import { useTranslation } from "react-i18next" import { ProjectFormData } from "./components/ProjectCreateService" -import Error from "../error/Error" import ProjectForm from "../../components/forms/ProjectForm" import { AppRoutes } from "../../@types/routes" import useAppApi from "../../hooks/useAppApi" @@ -14,104 +13,107 @@ import useApi from "../../hooks/useApi" import { ApiRoutes } from "../../@types/requests.d" const ProjectCreate: React.FC = () => { - const [form] = Form.useForm() - const { t } = useTranslation() - const navigate = useNavigate() - const { courseId } = useParams<{ courseId: string }>() - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) // Gebruik ProjectError type voor error state - const API = useApi() - const { message } = useAppApi() + const [form] = Form.useForm() + const { t } = useTranslation() + const navigate = useNavigate() + const { courseId } = useParams<{ courseId: string }>() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) // Gebruik ProjectError type voor error state + const API = useApi() + const { message } = useAppApi() - const handleCreation = async () => { - const values: ProjectFormData & DockerFormData = form.getFieldsValue() - const project: ProjectFormData = { - name: values.name, - description: values.description, - groupClusterId: values.groupClusterId, - deadline: values.deadline, - maxScore: values.maxScore, - testId: values.testId, - visible: values.visible, - } + const handleCreation = async () => { + const values: ProjectFormData & DockerFormData = form.getFieldsValue() + if (values.visible) { + values.visibleAfter = null + } - if (!courseId) return console.error("courseId is undefined") - setLoading(true) + const project: Omit = { + name: values.name, + description: values.description, + groupClusterId: values.groupClusterId, + deadline: values.deadline, + maxScore: values.maxScore, + testId: values.testId, + visible: values.visible, + visibleAfter: values.visibleAfter, + } - const response = await API.POST(ApiRoutes.PROJECT_CREATE, { body: project, pathValues: { courseId } }, "alert") - if (!response.success) { - setError(response.alert || null) - return setLoading(false) - } - const result = response.response.data + console.log(values) - await saveDockerForm( - form, - { - dockerImage: null, - dockerScript: null, - dockerTemplate: null, - structureTest: null, - }, - API - ) + if (!courseId) return console.error("courseId is undefined") + setLoading(true) - message.success(t("project.change.success")) // Toon een succesbericht - navigate(AppRoutes.PROJECT.replace(":projectId", result.projectId.toString()).replace(":courseId", courseId)) // Navigeer naar het nieuwe project + const response = await API.POST(ApiRoutes.PROJECT_CREATE, { body: project, pathValues: { courseId } }, "alert") + if (!response.success) { + setError(response.alert || null) + return setLoading(false) + } + const result = response.response.data + let promises: Promise[] = [] - } + promises.push(saveDockerForm(form, null, API, result.projectId.toString())) + if (form.isFieldTouched("groups") && values.groupClusterId && values.groups) { + promises.push(API.PUT(ApiRoutes.CLUSTER_FILL, { body: values.groups, pathValues: { id: values.groupClusterId } }, "message")) + } - const onInvalid: FormProps["onFinishFailed"] = (e) => { - const errField = e.errorFields[0].name[0] - if (errField === "groupClusterId") navigate("#groups") - else if (errField === "structureTest") navigate("#structure") - else if (errField === "dockerScript" || errField === "dockerImage" || errField === "dockerTemplate") navigate("#tests") - else navigate("#general") - } + await Promise.all(promises) - return ( - <> + message.success(t("project.change.success")) // Toon een succesbericht + navigate(AppRoutes.PROJECT.replace(":projectId", result.projectId.toString()).replace(":courseId", courseId)) // Navigeer naar het nieuwe project + } -
-
- - - - ), - }} - /> -
- - - ) + const onInvalid: FormProps["onFinishFailed"] = (e) => { + const errField = e.errorFields[0].name[0] + if (errField === "groupClusterId") navigate("#groups") + else if (errField === "structureTest") navigate("#structure") + else if (errField === "dockerScript" || errField === "dockerImage" || errField === "dockerTemplate") navigate("#tests") + else navigate("#general") + } + + return ( + <> +
+
+ + + + ), + }} + /> +
+ + + ) } export default ProjectCreate diff --git a/frontend/src/pages/projectCreate/components/GroupClusterDropdown.tsx b/frontend/src/pages/projectCreate/components/GroupClusterDropdown.tsx index a653c525..5c896901 100644 --- a/frontend/src/pages/projectCreate/components/GroupClusterDropdown.tsx +++ b/frontend/src/pages/projectCreate/components/GroupClusterDropdown.tsx @@ -1,47 +1,66 @@ import React, { FC, useEffect, useState } from "react" import { Button, Divider, Select, SelectProps, Space, Typography } from "antd" import { ApiRoutes } from "../../../@types/requests.d" -import apiCall from "../../../util/apiFetch" import { PlusOutlined } from "@ant-design/icons" import { useTranslation } from "react-i18next" import useAppApi from "../../../hooks/useAppApi" import GroupClusterModalContent from "./GroupClusterModalContent" import { ClusterType } from "../../course/components/groupTab/GroupsCard" +import useApi from "../../../hooks/useApi" -interface GroupClusterDropdownProps { +type GroupClusterDropdownProps = { courseId: string | number -} + onClusterCreated?: (clusterId: number) => void -const DropdownItem: FC<{ cluster: ClusterType, groupCountText:string, capacityText:string }> = ({ cluster,capacityText,groupCountText }) => ( -
- +} & SelectProps - {cluster.name} +const DropdownItem: FC<{ cluster: ClusterType; groupCountText: string; capacityText: string }> = ({ cluster, capacityText, groupCountText }) => ( +
+ + {cluster.name} - - {groupCountText}: {cluster.groupCount}, - {capacityText}: {cluster.capacity} - + + + {groupCountText}: {cluster.groupCount}, + + + {capacityText}: {cluster.capacity} +
) -const GroupClusterDropdown: React.FC = ({ courseId, ...args }) => { - const [clusters, setClusters] = useState([]) // Gebruik Cluster-interface +const GroupClusterDropdown: React.FC = ({ courseId,onClusterCreated, ...args }) => { + const [clusters, setClusters] = useState(null) // Gebruik Cluster-interface const [loading, setLoading] = useState(false) const { t } = useTranslation() const { modal } = useAppApi() + const API = useApi() + const groupCountText = t("project.change.amountOfGroups") + const capacityText = t("project.change.groupSize") + + const dropdownClusterItem = (cluster: ClusterType) => { + return { + label: ( + + ), + value: cluster.clusterId, + } + } useEffect(() => { const fetchClusters = async () => { setLoading(true) - const groupCountText = t("project.change.amountOfGroups") - const capacityText = t("project.change.groupSize") - try { - const response = await apiCall.get(ApiRoutes.COURSE_CLUSTERS, { id: courseId }) - const options: SelectProps["options"] = response.data.map((cluster: ClusterType) => ({ label: , value: cluster.clusterId })) - setClusters(options) // Zorg ervoor dat de nieuwe staat correct wordt doorgegeven + try { + const response = await API.GET(ApiRoutes.COURSE_CLUSTERS, { pathValues: { id: courseId } }) + if (!response.success) return + const clusters = response.response.data.map(dropdownClusterItem) + setClusters(clusters) // Zorg ervoor dat de nieuwe staat correct wordt doorgegeven } catch (error) { console.error("Error fetching clusters:", error) } finally { @@ -56,14 +75,14 @@ const GroupClusterDropdown: React.FC = const context = modal.info({ title: t("project.change.newGroupCluster"), icon: null, + width: 500, content: ( context.destroy()} onClusterCreated={(c) => { - const option = { label: c.name, value: c.clusterId } - setClusters((cl) => [...cl!, option]) - if (args.onChange) args.onChange(c.clusterId, option) + setClusters((clusters) => [...(clusters ?? []), dropdownClusterItem(c)]) + if (onClusterCreated) onClusterCreated(c.clusterId) context.destroy() }} /> @@ -75,8 +94,9 @@ const GroupClusterDropdown: React.FC = return ( +
+ +
+ + + ) } -export default SubmitForm +export default SubmitForm \ No newline at end of file diff --git a/frontend/src/providers/AuthProvider.tsx b/frontend/src/providers/AuthProvider.tsx new file mode 100644 index 00000000..82d39a60 --- /dev/null +++ b/frontend/src/providers/AuthProvider.tsx @@ -0,0 +1,78 @@ +import {createContext, FC, PropsWithChildren, useEffect, useState} from "react" +import {LoginStatus} from "../@types/appTypes"; +import apiCall from "../util/apiFetch"; +import {ApiRoutes} from "../@types/requests.d"; + +/** + * Context provider that contains the authentication state and account name for in the nav bar. + */ + + + +export type Account = { + name: string +} + +export type AuthContextProps = { + isAuthenticated: Boolean, + loginStatus: LoginStatus, + account: Account | null, + updateAccount: () => void, + login: () => void, + logout: () => void, +} + +const AuthContext = createContext({} as AuthContextProps) + +const AuthProvider : FC = ({children}) => { + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [loginStatus, setLoginStatus] = useState(LoginStatus.LOGIN_IN_PROGRESS) + const [account, setAccount] = useState(null) + + useEffect(() => { + updateAccount() + }, []); + + /** + * Function that contacts the backend for information on the current authentication state. + * Stores the result in the state. + */ + const updateAccount = async () => { + try { + const res = await apiCall.get(ApiRoutes.AUTH_INFO) + if (res.data.isAuthenticated) { + setIsAuthenticated(true) + setLoginStatus(LoginStatus.LOGGED_IN) + setAccount(res.data.account) + } else { + setIsAuthenticated(false) + setLoginStatus(LoginStatus.LOGGED_OUT) + setAccount(null) + } + } catch (err) { + console.log(err) + } + } + + /** + * Function that updates the login state. + * Should be used when logging in. + */ + const login = async () => { + setLoginStatus(LoginStatus.LOGIN_IN_PROGRESS) + } + + /** + * Function that updates the login state. + * Should be used when logging out. + */ + const logout = async () => { + setIsAuthenticated(false) + setLoginStatus(LoginStatus.LOGOUT_IN_PROGRESS) + } + + return {children} +} + + +export {AuthContext, AuthProvider} \ No newline at end of file diff --git a/frontend/src/providers/UserProvider.tsx b/frontend/src/providers/UserProvider.tsx index d35fadc6..24bcfea3 100644 --- a/frontend/src/providers/UserProvider.tsx +++ b/frontend/src/providers/UserProvider.tsx @@ -1,9 +1,11 @@ import { FC, PropsWithChildren, createContext, useEffect, useState } from "react" import { ApiRoutes, GET_Responses } from "../@types/requests.d" -import apiCall from "../util/apiFetch" -import { useIsAuthenticated, useMsal } from "@azure/msal-react" import { Spin } from "antd" -import { InteractionStatus } from "@azure/msal-browser" +import useApi from "../hooks/useApi" +import useAuth from "../hooks/useAuth"; +import { LoginStatus } from "../@types/appTypes"; +import { useLocalStorage } from "usehooks-ts" + type UserContextProps = { user: User | null @@ -18,40 +20,41 @@ const UserContext = createContext({} as UserContextProps) export type User = GET_Responses[ApiRoutes.USER] const UserProvider: FC = ({ children }) => { - const isAuthenticated = useIsAuthenticated() - const [user, setUser] = useState(null) - const [courses, setCourses] = useState(null) - const { inProgress } = useMsal() + + const auth = useAuth() + const [user, setUser] = useLocalStorage("__user_cache",null) + const [courses, setCourses] = useLocalStorage("__courses_cache",null) + const API = useApi() + useEffect(() => { - if (isAuthenticated) { + if (auth.isAuthenticated) { updateUser() + } else { + setUser(null) } - }, [isAuthenticated]) + }, [auth]) const updateCourses = async (userId: number | undefined = user?.id) => { if (!userId) return console.error("No user id provided") - try { - const res = await apiCall.get(ApiRoutes.USER_COURSES, { id: userId }) - setCourses(res.data) - } catch (err) { - // TODO: handle error - } + const res = await API.GET(ApiRoutes.USER_COURSES, { pathValues: { id: userId } },"page") + if (!res.success) return setCourses(null) + setCourses(res.response.data) } const updateUser = async () => { try { - let data = await apiCall.get(ApiRoutes.USER_AUTH) - - setUser(data.data) + const res = await API.GET(ApiRoutes.USER_AUTH, {}, "page") + if(!res.success) return setUser(null) + setUser(res.response.data) - await updateCourses(data.data.id) + await updateCourses(res.response.data.id) } catch (err) { console.log(err) } } - if (!user && (!(inProgress === InteractionStatus.Startup || inProgress === InteractionStatus.None || inProgress === InteractionStatus.Logout) || isAuthenticated)) + if (!user && (auth.loginStatus === LoginStatus.LOGIN_IN_PROGRESS || auth.loginStatus === LoginStatus.LOGOUT_IN_PROGRESS || auth.isAuthenticated)) return (
diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx index da3d732d..e6b3a83e 100644 --- a/frontend/src/router/AppRouter.tsx +++ b/frontend/src/router/AppRouter.tsx @@ -19,6 +19,7 @@ import CourseAdminView from "../hooks/CourseAdminView" // import ProjectTestsPage from "../pages/projectTest_old/ProjectTestPage"; import Courses from "../pages/courses/Courses" import EditProject from "../pages/editProject/EditProject" +import ExtraFilesDownload from "../pages/editProject/extrafilesDownload/ExtaFilesDownload" const AppRouter = () => { return ( @@ -85,6 +86,11 @@ const AppRouter = () => { path={AppRoutes.PROJECT} element={} /> + } + /> + {/* { - const isAuthenticated = useIsAuthenticated() - const { inProgress } = useMsal() + const auth = useAuth() const navigate = useNavigate() useEffect(() => { - if ((inProgress === InteractionStatus.None || inProgress === InteractionStatus.Logout ) && !isAuthenticated) { + if ((auth.loginStatus === LoginStatus.LOGGED_OUT || auth.loginStatus === LoginStatus.LOGOUT_IN_PROGRESS ) && !auth.isAuthenticated) { // instance.loginRedirect(loginRequest); console.log("NOT AUTHENTICATED"); navigate(AppRoutes.HOME) } - }, [isAuthenticated,inProgress]) + }, [auth]) - if (isAuthenticated) { + if (auth.isAuthenticated) { return } diff --git a/frontend/src/router/CourseRoutes.tsx b/frontend/src/router/CourseRoutes.tsx index 5e25dd1d..2f520374 100644 --- a/frontend/src/router/CourseRoutes.tsx +++ b/frontend/src/router/CourseRoutes.tsx @@ -4,9 +4,9 @@ import { CourseType } from "../pages/course/Course" import { Flex, Spin } from "antd" import useUser from "../hooks/useUser" import { UserCourseType } from "../providers/UserProvider" -import apiCall from "../util/apiFetch" import { ApiRoutes } from "../@types/requests.d" import useApi from "../hooks/useApi" +import { useSessionStorage } from "usehooks-ts" export type CourseContextType = { course: CourseType @@ -18,7 +18,7 @@ export const CourseContext = createContext({} as CourseContex const CourseRoutes: FC = () => { const { courseId } = useParams<{ courseId: string }>() - const [course, setCourse] = useState(null) + const [course, setCourse] = useSessionStorage("__course_cache_"+ courseId,null) const [member, setMember] = useState(null) const { courses } = useUser() const { GET } = useApi() @@ -35,10 +35,11 @@ const CourseRoutes: FC = () => { let ignore = false GET(ApiRoutes.COURSE, { pathValues: { courseId: courseId! } }, "page").then((res) => { - if (res.success && !ignore) { + if(ignore) return + if (res.success) { console.log("Course: ", res.response.data) setCourse(res.response.data) - } + } else setCourse(null) }) return () => { diff --git a/frontend/src/router/ProjectRoutes.tsx b/frontend/src/router/ProjectRoutes.tsx index 8fb5278d..dc4afaea 100644 --- a/frontend/src/router/ProjectRoutes.tsx +++ b/frontend/src/router/ProjectRoutes.tsx @@ -1,9 +1,9 @@ import { createContext, useEffect, useState } from "react" import { ProjectType } from "../pages/project/Project" import { Outlet, useParams } from "react-router-dom" -import apiCall from "../util/apiFetch" import { ApiRoutes } from "../@types/requests.d" import useApi from "../hooks/useApi" +import { useSessionStorage } from "usehooks-ts" type ProjectContextType = { project: ProjectType | null @@ -13,22 +13,21 @@ type ProjectContextType = { export const ProjectContext = createContext({} as ProjectContextType) const ProjectRoutes = () => { - const [project, setProject] = useState(null) const { projectId } = useParams() + const [project, setProject] = useSessionStorage("__project_cache_" + projectId, null) const { GET } = useApi() useEffect(() => { // TODO make api call `projectId` if (!projectId) return console.error("ProjectId is not defined") - - - let ignore = false console.log("Making the request", projectId) GET(ApiRoutes.PROJECT, { pathValues: { id: projectId! } }, "page").then((res) => { - if (res.success && !ignore) setProject(res.response.data) + if (ignore) return + if (res.success) setProject(res.response.data) + else setProject(null) }) return () => { @@ -41,7 +40,7 @@ const ProjectRoutes = () => { } return ( - + ) diff --git a/frontend/src/styles.css b/frontend/src/styles.css index dd90272c..f5592199 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -103,9 +103,15 @@ html { font-size: 28px; } -.ant-table-row > td.ant-table-column-sort, .ant-table-thead > tr > th.ant-table-column-sort { - background-color: unset ; +.ant-table-row > td.ant-table-column-sort { + background: unset ; } + +.modal-no-icon .ant-modal-confirm-paragraph, .modal-no-icon .ant-modal-confirm-body { + + display: initial; +} + /* *************************** Landing page *************************** */ .landing-page * { diff --git a/frontend/src/test/pages/home/Home.test.tsx.ignore b/frontend/src/test/pages/home/Home.test.tsx.ignore deleted file mode 100644 index 30af6cf8..00000000 --- a/frontend/src/test/pages/home/Home.test.tsx.ignore +++ /dev/null @@ -1,74 +0,0 @@ -import { render, screen } from "@testing-library/react" -import Home from "../../../pages/index/Home" -import { ApiRoutes, GET_Responses } from "../../../@types/requests" -import { UserCourseType } from "../../../providers/UserProvider" - -//TODO: Find better way to write all the mocks - -jest.mock("@azure/msal-react",()=> ({ - useAccount: jest.fn(() => ({})), - MsalAuthenticationTemplate: () => null, - useMsal: jest.fn(() => ({})), - MsalAuthenticationResult: () => ({}), -})) - -jest.mock("react-syntax-highlighter/dist/esm/styles/prism",()=> ({ - oneDark: {}, - oneLight: {} -})) - - -jest.mock('react-markdown', () => ({ - Markdown: () => null, -})); - -jest.mock("@azure/msal-react", () => ({ - useIsAuthenticated: () => true, -})) - - -window.matchMedia = window.matchMedia || function() { - return { - matches : false, - addListener : function() {}, - removeListener: function() {} - }; -}; - - -jest.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})) - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts - useNavigate: () => jest.fn(), // mock function only -})); - -jest.mock("../../../hooks/useUser", () => ({ - __esModule: true, // this property makes it work - default: () => { - const user: GET_Responses[ApiRoutes.USER] = { courseUrl: "/api/courses", projects_url: "/api/projects/1", url: "/api/users/12", role: "teacher", email: "test@gmail.com", id: 12, name: "Bob", surname: "test" } - const courses: UserCourseType[] = [{courseId:1,name:"Course 1", relation: "enrolled",memberCount : 3, url:"/api/courses/1",archivedAt: null}] - return { - user, - setUser: () => {}, - courses - } - }, -})) - - -test("rendersHome without crashing", () => { - render() - expect(screen.getByText("home.yourCourses")).toBeInTheDocument() - expect(screen.getByText("home.yourProjects")).toBeInTheDocument() - - - }) - -test("displays courses", () => { - render() - expect(screen.getByText("home.yourCourses")).toBeInTheDocument() - expect(screen.getByText("Course 1")).toBeInTheDocument() -}) \ No newline at end of file diff --git a/frontend/src/theme/themes/dark.ts b/frontend/src/theme/themes/dark.ts index f13b3e6d..b4e92ab1 100644 --- a/frontend/src/theme/themes/dark.ts +++ b/frontend/src/theme/themes/dark.ts @@ -22,6 +22,10 @@ export const darkTheme: ThemeConfig = { }, Calendar: { itemActiveBg: "#002b60" + }, + Table: { + headerSortActiveBg:"#252525" } - } + }, + }; diff --git a/frontend/src/theme/themes/light.ts b/frontend/src/theme/themes/light.ts index d9fe347c..2fbae02e 100644 --- a/frontend/src/theme/themes/light.ts +++ b/frontend/src/theme/themes/light.ts @@ -15,6 +15,9 @@ export const lightTheme: ThemeConfig = { Layout: { headerBg: "#1D64C7", headerHeight: 48, + }, + Table: { + headerSortActiveBg: "#f9f6fa" } } diff --git a/frontend/src/util/apiFetch.ts b/frontend/src/util/apiFetch.ts index d542607d..ff20260f 100644 --- a/frontend/src/util/apiFetch.ts +++ b/frontend/src/util/apiFetch.ts @@ -1,15 +1,18 @@ import { ApiRoutes, DELETE_Requests, GET_Responses, POST_Requests, POST_Responses, PUT_Requests, PUT_Responses } from "../@types/requests" -import axios, { AxiosError, AxiosResponse } from "axios" -import { msalInstance } from "../index" + + +import axios, { AxiosError, AxiosResponse, RawAxiosRequestHeaders } from "axios" + import { AxiosRequestConfig } from "axios" -import { msalConfig } from "../auth/AuthConfig" +import {file} from "jszip"; + + -const serverHost = window.location.origin.includes("localhost") ? "http://localhost:8080" : window.location.origin -let accessToken: string | null = null -let tokenExpiry: Date | null = null +const serverHost = window.location.origin.includes("localhost") ? "http://localhost:3000" : window.location.origin -export type ApiMethods = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" -export type ApiCallPathValues = {[param: string]: string | number} + +export type ApiMethods = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" +export type ApiCallPathValues = { [param: string]: string | number } /** * * @param method @@ -21,80 +24,45 @@ export type ApiCallPathValues = {[param: string]: string | number} * const newCourse = await apiFetch("POST", ApiRoutes.COURSES, { name: "New Course" }); * */ -export async function apiFetch(method: ApiMethods, route: string, body?: any, pathValues?:ApiCallPathValues): Promise> { - const account = msalInstance.getActiveAccount() - if (!account) { - throw Error("No active account found") - } +export async function apiFetch(method: ApiMethods, route: string, body?: any, pathValues?: ApiCallPathValues, headers?: RawAxiosRequestHeaders, config?: AxiosRequestConfig): Promise> { - if(pathValues) { + if (pathValues) { Object.entries(pathValues).forEach(([key, value]) => { - route = route.replace(":"+key, value.toString()) + route = route.replace(":" + key, value.toString()) }) } - // check if we have access token - const now = new Date() - if (!accessToken || !tokenExpiry || now >= tokenExpiry) { - const response = await msalInstance.acquireTokenSilent({ - scopes: [msalConfig.auth.clientId + "/.default"], - account: account, - }) - accessToken = response.accessToken - tokenExpiry = response.expiresOn // convert expiry time to JavaScript Date - } + const defaultHeaders = { + "Content-Type": body instanceof FormData ? undefined : "application/json", + } as RawAxiosRequestHeaders - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - } + const finalHeaders = headers ? { ...defaultHeaders, ...headers } : defaultHeaders const url = new URL(route, serverHost) - const config: AxiosRequestConfig = { + const finalConfig: AxiosRequestConfig = { method: method, url: url.toString(), - headers: headers, - data: body, + withCredentials:true, + headers: finalHeaders, + data: body instanceof FormData ? body : JSON.stringify(body), + ...config, // spread the config object to merge it with the existing configuration + } - - return axios(config) + return axios(finalConfig) } const apiCall = { - get: async (route: T, pathValues?:ApiCallPathValues) => apiFetch("GET", route,undefined,pathValues) as Promise>, - post: async (route: T, body: POST_Requests[T], pathValues?:ApiCallPathValues) => apiFetch("POST", route, body,pathValues) as Promise>, - put: async (route: T, body: PUT_Requests[T], pathValues?:ApiCallPathValues) => apiFetch("PUT", route, body,pathValues) as Promise>, - delete: async (route: T, body: DELETE_Requests[T], pathValues?:ApiCallPathValues) => apiFetch("DELETE", route, body,pathValues), - patch: async (route: T, body: Partial, pathValues?:ApiCallPathValues) => apiFetch("PATCH", route, body,pathValues) as Promise>, -} - -const apiCallInit = async () => { - const account = msalInstance.getActiveAccount() - - if (!account) { - throw Error("No active account found") - } - - const now = new Date() - if (!accessToken || !tokenExpiry || now >= tokenExpiry) { - const response = await msalInstance.acquireTokenSilent({ - scopes: [msalConfig.auth.clientId + "/.default"], - account: account, - }) - - accessToken = response.accessToken - tokenExpiry = response.expiresOn // convert expiry time to JavaScript Date - return accessToken - } else{ - return accessToken - } + get: async (route: T, pathValues?: ApiCallPathValues, headers?: RawAxiosRequestHeaders, config?: AxiosRequestConfig) => apiFetch("GET", route, undefined, pathValues, headers, config) as Promise>, + post: async (route: T, body: POST_Requests[T] | FormData, pathValues?: ApiCallPathValues, headers?: RawAxiosRequestHeaders) => apiFetch("POST", route, body, pathValues, headers) as Promise>, + put: async (route: T, body: PUT_Requests[T], pathValues?: ApiCallPathValues, headers?: RawAxiosRequestHeaders) => apiFetch("PUT", route, body, pathValues, headers) as Promise>, + delete: async (route: T, body: DELETE_Requests[T], pathValues?: ApiCallPathValues, headers?: RawAxiosRequestHeaders) => apiFetch("DELETE", route, body, pathValues, headers), + patch: async (route: T, body: Partial, pathValues?: ApiCallPathValues, headers?: RawAxiosRequestHeaders) => apiFetch("PATCH", route, body, pathValues, headers) as Promise>, } -export { accessToken,apiCallInit } export default apiCall diff --git a/frontend/src/util/backendServer.ts b/frontend/src/util/backendServer.ts new file mode 100644 index 00000000..ec14c445 --- /dev/null +++ b/frontend/src/util/backendServer.ts @@ -0,0 +1,3 @@ + +export const BACKEND_SERVER = window.location.origin.includes("localhost") ? "http://localhost:3000" : window.location.origin + diff --git a/gha b/gha deleted file mode 100644 index e69de29b..00000000 diff --git a/nginx/conf/app.conf b/nginx/conf/app.conf index e3960ff3..0c83679b 100644 --- a/nginx/conf/app.conf +++ b/nginx/conf/app.conf @@ -1,38 +1,68 @@ -server { - listen 80; - listen [::]:80; +http { + map $request_method $index_req { + POST "index_post"; + default "default_index"; # A default value to prevent errors. + } + + upstream express_server { + server localhost:3000; + } + + + + server { + listen 80; + listen [::]:80; - server_name sel2-6.ugent.be www.sel2-6.ugent.be; - server_tokens off; + server_name sel2-6.ugent.be www.sel2-6.ugent.be; + server_tokens off; - location /.well-known/acme-challenge/ { + location /.well-known/acme-challenge/ { root /var/www/certbot; - } + } - location / { - return 301 https://sel2-6.ugent.be$request_uri; - } -} + location / { + return 301 https://sel2-6.ugent.be$request_uri; + } + } -server { - listen 443 default_server ssl http2; - listen [::]:443 ssl http2; + server { + listen 443 default_server ssl http2; + listen [::]:443 ssl http2; - server_name sel2-6.ugent.be; + server_name sel2-6.ugent.be; - ssl_certificate /etc/nginx/ssl/live/sel2-6.ugent.be/fullchain.pem; - ssl_certificate_key /etc/nginx/ssl/live/sel2-6.ugent.be/privkey.pem; + ssl_certificate /etc/nginx/ssl/live/sel2-6.ugent.be/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/live/sel2-6.ugent.be/privkey.pem; - location /api/ { - proxy_pass http://spring_container:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Authorization $http_authorization; - } - location / { - root /usr/share/nginx/html/build; - try_files $uri $uri/ /index.html; - } + location /api/ { + proxy_pass http://spring_container:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + break; + } + + location /web/ { + proxy_pass http://express_server; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + break; + } + + location / { + + if ($index_req = "index_post") { + proxy_pass http://express_server; + break; + } + + root /usr/share/nginx/html/build; + try_files $uri $uri/ /index.html; + } + } } diff --git a/startBackend.sh b/startBackend.sh deleted file mode 100755 index ec1fe7ab..00000000 --- a/startBackend.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -export PGU=tristanIs -export PGP=fuckingHeet -open -a Docker -docker compose up db -d -/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home/bin/java -XX:TieredStopAtLevel=1 -Dspring.output.ansi.enabled=always -Dcom.sun.management.jmxremote -Dspring.jmx.enabled=true -Dspring.liveBeansView.mbeanDomain -Dspring.application.admin.enabled=true -Dmanagement.endpoints.jmx.exposure.include=* -Dfile.encoding=UTF-8 -classpath /Users/usserwout/Documents/school/3ba/SEL2/UGent-6/backend/app/build/classes/java/main:/Users/usserwout/Documents/school/3ba/SEL2/UGent-6/backend/app/build/resources/main:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-web/3.2.2/b89d213d9f49c3e6247b2503ac7d94b0ac8260f6/spring-boot-starter-web-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-oauth2-client/3.2.2/cce33514a28968b4f6203cdcce700192d19b1ef0/spring-boot-starter-oauth2-client-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-security/3.2.2/79676d6fe68878890e26be10ecab126f472d7c0f/spring-boot-starter-security-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-oauth2-resource-server/3.2.2/8d71b3dd52be0068cb505de076493c991085a5f1/spring-boot-starter-oauth2-resource-server-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.azure.spring/azure-spring-boot-starter-active-directory/3.11.0/6e0605d340d45896a875fdbb0bb0da0e1c70d57a/azure-spring-boot-starter-active-directory-3.11.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.security.oauth.boot/spring-security-oauth2-autoconfigure/2.1.8.RELEASE/ad308707f69f4a66a1e19fdd91389058a9942eba/spring-security-oauth2-autoconfigure-2.1.8.RELEASE.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.security/spring-security-config/6.2.1/119953fd2980d50e1119b913a8596e5e6bdc1295/spring-security-config-6.2.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.security/spring-security-web/6.2.1/4b486977ab1bcdd5dcc6aa5d8a367f1c0814bf56/spring-security-web-6.2.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.azure.spring/azure-spring-boot-starter/3.11.0/776d5a559e1746e11249bac2993349d0f5b65c6/azure-spring-boot-starter-3.11.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.auth0/java-jwt/3.18.2/89c1da37cd738d9c3c7176fbf1e291ff2a8b988/java-jwt-3.18.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.auth0/jwks-rsa/0.18.0/cd3977e3234ddd06e2612812308fa621bf27d7e6/jwks-rsa-0.18.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/javax.servlet/javax.servlet-api/4.0.1/a27082684a2ff0bf397666c3943496c44541d1ca/javax.servlet-api-4.0.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-data-jpa/3.2.2/65cf3aad09f0218b7dfab849c9b0350d0a9e0d81/spring-boot-starter-data-jpa-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.tika/tika-core/1.27/79ad0f72558b8fbce947147959e2faff8b7b70a/tika-core-1.27.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.github.docker-java/docker-java-transport-httpclient5/3.3.5/73a916e8931e0d09c8e02ab5e90f38db80dcd2ff/docker-java-transport-httpclient5-3.3.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.httpcomponents.client5/httpclient5/5.1.2/54d84f95fe7d182a86e6e3064a77b58478e2b5a/httpclient5-5.1.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.github.docker-java/docker-java/3.3.5/3f1cdd5f182c53ffd15d7553b8b3d1c96e74ae1b/docker-java-3.3.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-devtools/3.2.2/5d4ce10e0c8d4a6cc040a1836280f379893a8213/spring-boot-devtools-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-json/3.2.2/328f5ce9e10d5f90520e72a3ff8a2586b9e46b37/spring-boot-starter-json-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter/3.2.2/dc04714f9295297f92fa8099eb51edc54dbe67db/spring-boot-starter-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-tomcat/3.2.2/e22a0ba37910731b382f3fe47ad36aed20fad24d/spring-boot-starter-tomcat-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-webmvc/6.1.3/f4738a57787add6567e0679eebb1b499a11019cc/spring-webmvc-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-web/6.1.3/cc3459b4abd436331608ddb6424886875f7086ab/spring-web-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.security/spring-security-oauth2-client/6.2.1/1e78c49fa10f72acb7f40e34e9ced1216753792c/spring-security-oauth2-client-6.2.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.security/spring-security-oauth2-jose/6.2.1/9bb10fe87a2dfff27075dd09586075645907b44d/spring-security-oauth2-jose-6.2.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.security/spring-security-core/6.2.1/b4014a04f217f0f48d15bc7d53906b6911ad855f/spring-security-core-6.2.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-aop/6.1.3/4d9bd4bd9b8bedf9ef151b45c79766b336117b9a/spring-aop-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.security/spring-security-oauth2-resource-server/6.2.1/3d36a1686bdd96d6cd8f90f34bef7b59b4b7feb2/spring-security-oauth2-resource-server-6.2.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-webflux/3.2.2/25ae11864604f8f7b504b9feae710b859aed6464/spring-boot-starter-webflux-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-validation/3.2.2/faedd363ffbafde6a23bc7183ce79de11a96e780/spring-boot-starter-validation-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.azure.spring/azure-spring-boot/3.11.0/761a2ace1da268ad666ac21abc834c5077a3e00/azure-spring-boot-3.11.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.microsoft.azure/msal4j/1.11.0/38df9693f67ea1f01f35ebbe9411f0760c9ac77f/msal4j-1.11.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.nimbusds/nimbus-jose-jwt/9.24.4/29a1f6a00a4daa3e1873f6bf4f16ddf4d6fd6d37/nimbus-jose-jwt-9.24.4.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.fasterxml.jackson.core/jackson-databind/2.15.3/a734bc2c47a9453c4efa772461a3aeb273c010d9/jackson-databind-2.15.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.projectreactor.netty/reactor-netty/1.1.15/a6a0ff5228472763c9034353711078057c0d8074/reactor-netty-1.1.15.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.security.oauth/spring-security-oauth2/2.3.5.RELEASE/7969f5363398d6d3788bef1740b2ab9509043d51/spring-security-oauth2-2.3.5.RELEASE.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-autoconfigure/3.2.2/5c407409f8d260a4bc6e173d16fc3b36e6adec21/spring-boot-autoconfigure-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot/3.2.2/9f274d1bd822c4c57bb5b37ecae2380b980f567/spring-boot-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.fasterxml.jackson.core/jackson-annotations/2.15.3/79baf4e605eb3bbb60b1c475d44a7aecceea1d60/jackson-annotations-2.15.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/javax.xml.bind/jaxb-api/2.3.1/8531ad5ac454cc2deb9d4d32c40c4d7451939b5d/jaxb-api-2.3.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.security/spring-security-jwt/1.0.10.RELEASE/19a1ca7a83e9d263a31af5f529da460f8f863451/spring-security-jwt-1.0.10.RELEASE.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-context/6.1.3/c63f038933701058fd7578460c66dbe2d424915/spring-context-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-beans/6.1.3/c2df4210e796d3a27efc1f22621aa4e2c6cd985f/spring-beans-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-core/6.1.3/a002e96e780954cc3ac4cd70fd3bb16accdc47ed/spring-core-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-expression/6.1.3/7c35fc3d7525a024fdde8a5d7597a6a8a4e59d7/spring-expression-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-aop/3.2.2/f01ecef0ce5f8d5631890a0c456a88a72323b569/spring-boot-starter-aop-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-jdbc/3.2.2/22ffda6938dca5f584c8b1b64e4e9096e8302c1e/spring-boot-starter-jdbc-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.data/spring-data-jpa/3.2.2/f91a3896c2a6139ac1da1fd8ff4350ca4b0e409e/spring-data-jpa-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.hibernate.orm/hibernate-core/6.4.1.Final/3dcefddf6609e6491d37208bcc0cab1273598cbd/hibernate-core-6.4.1.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-aspects/6.1.3/c8b5dde3568dc5df6109916d8ad4866efe4e61fd/spring-aspects-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.slf4j/slf4j-api/2.0.11/ad96c3f8cf895e696dd35c2bc8e8ebe710be9e6d/slf4j-api-2.0.11.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/commons-io/commons-io/2.13.0/8bb2bc9b4df17e2411533a0708a69f983bf5e83b/commons-io-2.13.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.github.docker-java/docker-java-transport/3.3.5/4aa7e97c14ed1f2ca62029bf1ea8467f6ebf48d9/docker-java-transport-3.3.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/net.java.dev.jna/jna/5.13.0/1200e7ebeedbe0d10062093f32925a912020e747/jna-5.13.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.httpcomponents.core5/httpcore5-h2/5.2.4/2872764df7b4857549e2880dd32a6f9009166289/httpcore5-h2-5.2.4.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.httpcomponents.core5/httpcore5/5.2.4/34d8332b975f9e9a8298efe4c883ec43d45b7059/httpcore5-5.2.4.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/commons-codec/commons-codec/1.16.0/4e3eb3d79888d76b54e28b350915b5dc3919c9de/commons-codec-1.16.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.github.docker-java/docker-java-transport-jersey/3.3.5/5b74264f3cb7af6efa83fa9ba8f6540c4a63d641/docker-java-transport-jersey-3.3.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.github.docker-java/docker-java-transport-netty/3.3.5/a416c8c75d216893a11a8167952267876e5977ec/docker-java-transport-netty-3.3.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.github.docker-java/docker-java-core/3.3.5/d30fb6b25c0bde6a0f0f25bcb2cde12e23a44422/docker-java-core-3.3.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.slf4j/jcl-over-slf4j/2.0.11/f6226edb8c85f8c9f1f75ec4b0252c02f589478a/jcl-over-slf4j-2.0.11.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.fasterxml.jackson.datatype/jackson-datatype-jsr310/2.15.3/4a20a0e104931bfa72f24ef358c2eb63f1ef2aaf/jackson-datatype-jsr310-2.15.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.fasterxml.jackson.module/jackson-module-parameter-names/2.15.3/8d251b90c5358677e7d8161e0c2488e6f84f49da/jackson-module-parameter-names-2.15.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.fasterxml.jackson.datatype/jackson-datatype-jdk8/2.15.3/80158cb020c7bd4e4ba94d8d752a65729dc943b2/jackson-datatype-jdk8-2.15.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-logging/3.2.2/3347c3b1cec6cf2d5fa186d1e49d2f378a6b7cae/spring-boot-starter-logging-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/jakarta.annotation/jakarta.annotation-api/2.1.1/48b9bda22b091b1f48b13af03fe36db3be6e1ae3/jakarta.annotation-api-2.1.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.yaml/snakeyaml/2.2/3af797a25458550a16bf89acc8e4ab2b7f2bfce0/snakeyaml-2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.tomcat.embed/tomcat-embed-websocket/10.1.18/83a3bc6898f2ceed2357ba231a5e83dc2016d454/tomcat-embed-websocket-10.1.18.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.tomcat.embed/tomcat-embed-core/10.1.18/bff6c34649d1dd7b509e819794d73ba795947dcf/tomcat-embed-core-10.1.18.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.tomcat.embed/tomcat-embed-el/10.1.18/b2c4dc05abd363c63b245523bb071727aa2f1046/tomcat-embed-el-10.1.18.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.micrometer/micrometer-observation/1.12.2/e082b05a2527fc24ea6fbe4c4b7ae34653aace81/micrometer-observation-1.12.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.security/spring-security-oauth2-core/6.2.1/1927cdcddce10f1f852e25ed3445e1f1b96c17ad/spring-security-oauth2-core-6.2.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.nimbusds/oauth2-oidc-sdk/9.43.3/31709dab9f6531cc5c8f0d7e50ed5ccf10127877/oauth2-oidc-sdk-9.43.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.security/spring-security-crypto/6.2.1/d7c4f4e8fe5ae84dd1da76094ee8a0a7e214923d/spring-security-crypto-6.2.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-webflux/6.1.3/426447b8e64765db5c2901f2b33cdb691b845f34/spring-webflux-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.boot/spring-boot-starter-reactor-netty/3.2.2/cf32e5dc15e455a17aa833eeefb9d0f0eb9f6c4e/spring-boot-starter-reactor-netty-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.hibernate.validator/hibernate-validator/8.0.1.Final/e49e116b3d3928060599b176b3538bb848718e95/hibernate-validator-8.0.1.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/javax.validation/validation-api/2.0.1.Final/cb855558e6271b1b32e716d24cb85c7f583ce09e/validation-api-2.0.1.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/javax.annotation/javax.annotation-api/1.3.2/934c04d3cfef185a8008e7bf34331b79730a9d43/javax.annotation-api-1.3.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.github.stephenc.jcip/jcip-annotations/1.0-1/ef31541dd28ae2cefdd17c7ebf352d93e9058c63/jcip-annotations-1.0-1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.fasterxml.jackson.core/jackson-core/2.15.3/60d600567c1862840397bf9ff5a92398edc5797b/jackson-core-2.15.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.projectreactor.netty/reactor-netty-http/1.1.15/c79756fa2dfc28ac81fc9d23a14b17c656c3e560/reactor-netty-http-1.1.15.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.projectreactor.netty/reactor-netty-core/1.1.15/3221d405ad55a573cf29875a8244a4217cf07185/reactor-netty-core-1.1.15.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.codehaus.jackson/jackson-mapper-asl/1.9.13/1ee2f2bed0e5dd29d1cb155a166e6f8d50bbddb7/jackson-mapper-asl-1.9.13.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/javax.activation/javax.activation-api/1.2.0/85262acf3ca9816f9537ca47d5adeabaead7cb16/javax.activation-api-1.2.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.bouncycastle/bcpkix-jdk15on/1.60/d0c46320fbc07be3a24eb13a56cee4e3d38e0c75/bcpkix-jdk15on-1.60.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-jcl/6.1.3/a715e091ee86243ee94534a03f3c26b4e48de31e/spring-jcl-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.aspectj/aspectjweaver/1.9.21/beaabaea95c7f3330f415c72ee0ffe79b51d172f/aspectjweaver-1.9.21.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-jdbc/6.1.3/be4b30cc956b26f13e04ccadc2b0575038c531bb/spring-jdbc-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.zaxxer/HikariCP/5.0.1/a74c7f0a37046846e88d54f7cb6ea6d565c65f9c/HikariCP-5.0.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-orm/6.1.3/98572e26c6d011c9710545085358a4e35e27649/spring-orm-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework.data/spring-data-commons/3.2.2/9b0b0f5f5bc793463a81171d6889809abc14b19b/spring-data-commons-3.2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.springframework/spring-tx/6.1.3/7750337bf46a2ff248685915c7cc88d3bef2f666/spring-tx-6.1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.antlr/antlr4-runtime/4.13.0/5a02e48521624faaf5ff4d99afc88b01686af655/antlr4-runtime-4.13.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/jakarta.persistence/jakarta.persistence-api/3.1.0/66901fa1c373c6aff65c13791cc11da72060a8d6/jakarta.persistence-api-3.1.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/jakarta.transaction/jakarta.transaction-api/2.0.1/51a520e3fae406abb84e2e1148e6746ce3f80a1a/jakarta.transaction-api-2.0.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.fasterxml.jackson.jaxrs/jackson-jaxrs-json-provider/2.15.3/71edfaa76deaaa3e8848d8e35e485f132d095f81/jackson-jaxrs-json-provider-2.15.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.jersey.connectors/jersey-apache-connector/3.1.5/ec06316a19338bcc8236d6d5ac38b273ffbbd5cd/jersey-apache-connector-3.1.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.httpcomponents/httpclient/4.5.14/1194890e6f56ec29177673f2f12d0b8e627dec98/httpclient-4.5.14.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.jersey.core/jersey-client/3.1.5/15695e853b7583703aff98e543b95fa0ca4553/jersey-client-3.1.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.jersey.inject/jersey-hk2/3.1.5/9ecb5339c3de02e5939c72657e74e2c5fdeb71c8/jersey-hk2-3.1.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.httpcomponents/httpcore/4.4.16/51cf043c87253c9f58b539c9f7e44c8894223850/httpcore-4.4.16.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.kohlschutter.junixsocket/junixsocket-common/2.6.1/34151df0d8c8348ddb9f2f387943b3238b5f1a4f/junixsocket-common-2.6.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.kohlschutter.junixsocket/junixsocket-native-common/2.6.1/70a02ed1d3fab753ac436732d4aab877ada8b50f/junixsocket-native-common-2.6.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-handler-proxy/4.1.105.Final/19a5b78164c6a5a0464586c1e1ac5695cc79844c/netty-handler-proxy-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-codec-http/4.1.105.Final/bc8bc7b5384fb3dcb467d2a44159282935328779/netty-codec-http-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-handler/4.1.105.Final/7e997e63d0a445c4b352bcd38474d50f06f2eaf1/netty-handler-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-transport-native-epoll/4.1.105.Final/8d20e17cff9ec1aaa3bb133ae7cb339c991bc105/netty-transport-native-epoll-4.1.105.Final-linux-x86_64.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-transport-native-kqueue/4.1.105.Final/91e3e877f65e4a485d5cca980e59329034152b43/netty-transport-native-kqueue-4.1.105.Final-osx-x86_64.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.github.docker-java/docker-java-api/3.3.5/c9cd924da119835a8da0ca43bfa37b740247c029/docker-java-api-3.3.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.commons/commons-compress/1.21/4ec95b60d4e86b5c95a0e919cb172a0af98011ef/commons-compress-1.21.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.commons/commons-lang3/3.13.0/b7263237aa89c1f99b327197c41d0669707a462e/commons-lang3-3.13.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.google.guava/guava/19.0/6ce200f6b23222af3d8abb6b6459e6c44f4bb0e9/guava-19.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.bouncycastle/bcpkix-jdk18on/1.76/10c9cf5c1b4d64abeda28ee32fbade3b74373622/bcpkix-jdk18on-1.76.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/ch.qos.logback/logback-classic/1.4.14/d98bc162275134cdf1518774da4a2a17ef6fb94d/logback-classic-1.4.14.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.logging.log4j/log4j-to-slf4j/2.21.1/d77b2ba81711ed596cd797cc2b5b5bd7409d841c/log4j-to-slf4j-2.21.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.slf4j/jul-to-slf4j/2.0.11/279356f8e873b1a26badd8bbb3284b5c3b22c770/jul-to-slf4j-2.0.11.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.micrometer/micrometer-commons/1.12.2/b44127d8ec7b3ef11a01912d1e6474e1167f3929/micrometer-commons-1.12.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.nimbusds/content-type/2.2/9a894bce7646dd4086652d85b88013229f23724b/content-type-2.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/net.minidev/json-smart/2.5.0/57a64f421b472849c40e77d2e7cce3a141b41e99/json-smart-2.5.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.nimbusds/lang-tag/1.7/97c73ecd70bc7e8eefb26c5eea84f251a63f1031/lang-tag-1.7.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.projectreactor/reactor-core/3.6.2/679ac38d031c154374182748491a177a76c890e1/reactor-core-3.6.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/jakarta.validation/jakarta.validation-api/3.0.2/92b6631659ba35ca09e44874d3eb936edfeee532/jakarta.validation-api-3.0.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.jboss.logging/jboss-logging/3.5.3.Final/c88fc1d8a96d4c3491f55d4317458ccad53ca663/jboss-logging-3.5.3.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.fasterxml/classmate/1.6.0/91affab6f84a2182fce5dd72a8d01bc14346dddd/classmate-1.6.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-codec-http2/4.1.105.Final/7c558b1ea68a2385b250191ad5ecb0f4afb2d866/netty-codec-http2-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-resolver-dns-native-macos/4.1.105.Final/1c3b820b9f44c26a65dec438a731f8c9bf64004/netty-resolver-dns-native-macos-4.1.105.Final-osx-x86_64.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-resolver-dns/4.1.105.Final/719fa5bccd87bd3286fc0655a6c3b61cb539e334/netty-resolver-dns-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.codehaus.jackson/jackson-core-asl/1.9.13/3c304d70f42f832e0a86d45bd437f692129299a4/jackson-core-asl-1.9.13.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.bouncycastle/bcprov-jdk15on/1.60/bd47ad3bd14b8e82595c7adaa143501e60842a84/bcprov-jdk15on-1.60.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.fasterxml.jackson.module/jackson-module-jaxb-annotations/2.15.3/74e8ef60b65b42051258465f06c06195e61e92f2/jackson-module-jaxb-annotations-2.15.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.fasterxml.jackson.jaxrs/jackson-jaxrs-base/2.15.3/44b0de22ca9331c21810569154e7d76450be1c6f/jackson-jaxrs-base-2.15.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.jersey.core/jersey-common/3.1.5/7a9edf47631e6588cf24f777f3e7f183d285a9e1/jersey-common-3.1.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/jakarta.ws.rs/jakarta.ws.rs-api/3.1.0/15ce10d249a38865b58fc39521f10f29ab0e3363/jakarta.ws.rs-api-3.1.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/jakarta.inject/jakarta.inject-api/2.0.1/4c28afe1991a941d7702fe1362c365f0a8641d1e/jakarta.inject-api-2.0.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.hk2/hk2-locator/3.0.5/ea4a4d2c187dead10c998ebb3c3d6ce5133f5637/hk2-locator-3.0.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.javassist/javassist/3.29.2-GA/6c32028609e5dd4a1b78e10fbcd122b09b3928b1/javassist-3.29.2-GA.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-codec-socks/4.1.105.Final/47570154d4cf9d8d9569a25ec1929d8ad1b0fa68/netty-codec-socks-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-codec/4.1.105.Final/4733830b4b2b9111e9bf8136691d07b20027cd51/netty-codec-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-transport/4.1.105.Final/5184e1308ed7d853d7061b4e21e47e8de43a28df/netty-transport-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-buffer/4.1.105.Final/c1c1b4d2c89d2f74c80a2701c124ecfde1ecf067/netty-buffer-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-common/4.1.105.Final/8438fc1de4c10301bb53ceb49ed8db74bd40cc2c/netty-common-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-transport-native-unix-common/4.1.105.Final/8bb8ab3a8f1e730c6e7d1d1cf8fc24221eacc6cd/netty-transport-native-unix-common-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-resolver/4.1.105.Final/e69687e013ded60f3f9054164e121c2702200711/netty-resolver-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-transport-classes-epoll/4.1.105.Final/e006d3b0cfd5b8133c9fcebfa05d9ae80a721e80/netty-transport-classes-epoll-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-transport-classes-kqueue/4.1.105.Final/aadd137239a02aaae8c1de0f4346e8af8898e90f/netty-transport-classes-kqueue-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.bouncycastle/bcutil-jdk18on/1.76/8c7594e651a278bcde18e038d8ab55b1f97f4d31/bcutil-jdk18on-1.76.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.bouncycastle/bcprov-jdk18on/1.76/3a785d0b41806865ad7e311162bfa3fa60b3965b/bcprov-jdk18on-1.76.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/ch.qos.logback/logback-core/1.4.14/4d3c2248219ac0effeb380ed4c5280a80bf395e8/logback-core-1.4.14.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.apache.logging.log4j/log4j-api/2.21.1/74c65e87b9ce1694a01524e192d7be989ba70486/log4j-api-2.21.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/net.minidev/accessors-smart/2.5.0/aca011492dfe9c26f4e0659028a4fe0970829dd8/accessors-smart-2.5.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.reactivestreams/reactive-streams/1.0.4/3864a1320d97d7b045f729a326e1e077661f31b7/reactive-streams-1.0.4.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-resolver-dns-classes-macos/4.1.105.Final/91c67d50bad110e1430b9dbda0f10a7768e2e13c/netty-resolver-dns-classes-macos-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty/netty-codec-dns/4.1.105.Final/81a24958441b3f27c3404706202388202690416d/netty-codec-dns-4.1.105.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/jakarta.xml.bind/jakarta.xml.bind-api/4.0.1/ca2330866cbc624c7e5ce982e121db1125d23e15/jakarta.xml.bind-api-4.0.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/jakarta.activation/jakarta.activation-api/2.1.2/640c0d5aff45dbff1e1a1bc09673ff3a02b1ba12/jakarta.activation-api-2.1.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.hk2/osgi-resource-locator/1.0.3/de3b21279df7e755e38275137539be5e2c80dd58/osgi-resource-locator-1.0.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.hk2/hk2-api/3.0.5/6774367a6780ea4fedc19425981f1b86762a3506/hk2-api-3.0.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.hk2.external/aopalliance-repackaged/3.0.5/6a77d3f22a1423322226bff412177addc936b38f/aopalliance-repackaged-3.0.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.hk2/hk2-utils/3.0.5/4d65eff85bd778f66e448be1049be8b9530a028f/hk2-utils-3.0.5.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.ow2.asm/asm/9.3/8e6300ef51c1d801a7ed62d07cd221aca3a90640/asm-9.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.postgresql/postgresql/42.6.0/7614cfce466145b84972781ab0079b8dea49e363/postgresql-42.6.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.jaxb/jaxb-runtime/4.0.4/7180c50ef8bd127bb1dd645458b906cffcf6c2b5/jaxb-runtime-4.0.4.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.google.guava/guava/30.0-jre/8ddbc8769f73309fe09b54c5951163f10b0d89fa/guava-30.0-jre.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.checkerframework/checker-qual/3.31.0/eeefd4af42e2f4221d145c1791582f91868f99ab/checker-qual-3.31.0.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.projectreactor.netty.incubator/reactor-netty-incubator-quic/0.1.15/22fee3c4bcb10e725a2c627c5f9f7912ebf450e/reactor-netty-incubator-quic-0.1.15.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.jaxb/jaxb-core/4.0.4/2d5aadd02af86f1e9d8c6f7e8501673f915d4e25/jaxb-core-4.0.4.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.google.guava/failureaccess/1.0.1/1dcf1de382a0bf95a3d8b0849546c88bac1292c9/failureaccess-1.0.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.google.guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/b421526c5f297295adef1c886e5246c39d4ac629/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.google.code.findbugs/jsr305/3.0.2/25ea2e8b0c338a877313bd4672d3fe056ea78f0d/jsr305-3.0.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.google.errorprone/error_prone_annotations/2.3.4/dac170e4594de319655ffb62f41cbd6dbb5e601e/error_prone_annotations-2.3.4.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.google.j2objc/j2objc-annotations/1.3/ba035118bc8bac37d7eff77700720999acd9986d/j2objc-annotations-1.3.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.hibernate.common/hibernate-commons-annotations/6.0.6.Final/77a5f94b56d49508e0ee334751db5b78e5ccd50c/hibernate-commons-annotations-6.0.6.Final.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.smallrye/jandex/3.1.2/a6c1c89925c7df06242b03dddb353116ceb9584c/jandex-3.1.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/net.bytebuddy/byte-buddy/1.14.11/725602eb7c8c56b51b9c21f273f9df5c909d9e7d/byte-buddy-1.14.11.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty.incubator/netty-incubator-codec-native-quic/0.0.55.Final/cb688a13f9867eecc490d489e7cadd06f061eb6a/netty-incubator-codec-native-quic-0.0.55.Final-linux-x86_64.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.eclipse.angus/angus-activation/2.0.1/eaafaf4eb71b400e4136fc3a286f50e34a68ecb7/angus-activation-2.0.1.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/org.glassfish.jaxb/txw2/4.0.4/cfd2bcf08782673ac370694fdf2cf76dbaa607ef/txw2-4.0.4.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/com.sun.istack/istack-commons-runtime/4.1.2/18ec117c85f3ba0ac65409136afa8e42bc74e739/istack-commons-runtime-4.1.2.jar:/Users/usserwout/.gradle/caches/modules-2/files-2.1/io.netty.incubator/netty-incubator-codec-classes-quic/0.0.55.Final/9e21132ee25bd87de11313d77685fe135fa30fd/netty-incubator-codec-classes-quic-0.0.55.Final.jar com.ugent.pidgeon.PidgeonApplication - -exec $SHELL \ No newline at end of file