From 1a6699aa6752de8193f5b81ca9e3f9b7fc9aeaef Mon Sep 17 00:00:00 2001 From: Michael Breu Date: Fri, 11 Oct 2024 14:25:46 +0200 Subject: [PATCH] Basic Sharing Connector Activation --- .idea/runConfigurations/Artemis_Dev.xml | 18 + build.gradle | 2 + .../artemis-dev-local-vc-local-ci-mysql.yml | 1 + docker/artemis.yml | 1 + .../artemis/config/dev-local-vc-local-ci.env | 2 +- .../aet/artemis/core/config/Constants.java | 19 +- .../core/config/SecurityConfiguration.java | 2 + .../aet/artemis/core/dto/SharingInfoDTO.java | 102 ++++ .../artemis/core/service/ProfileService.java | 9 + .../core/web/SharingSupportResource.java | 98 ++++ ...mmingExerciseImportFromSharingService.java | 76 +++ .../sharing/SharingConnectorService.java | 174 +++++++ .../service/sharing/SharingException.java | 42 ++ .../service/sharing/SharingPluginService.java | 152 ++++++ .../exercise/web/ExerciseSharingResource.java | 191 ++++++++ ...grammingExerciseImportFromFileService.java | 73 ++- .../sharing/ExerciseSharingService.java | 455 ++++++++++++++++++ .../sharing/SharingMultipartZipFile.java | 82 ++++ .../aet/artemis/sharing/SharingSetupInfo.java | 36 ++ src/main/resources/config/application-dev.yml | 2 +- .../resources/config/application-sharing.yml | 15 + src/main/webapp/app/app-routing.module.ts | 4 + ...programming-exercise-detail.component.html | 1 + .../programming-exercise-detail.component.ts | 9 + ...ming-exercise-management-routing.module.ts | 25 + .../programming-exercise-management.module.ts | 2 + .../manage/programming-exercise.component.ts | 2 + .../programming-exercise-sharing.service.ts | 135 ++++++ .../programming-exercise-creation-config.ts | 1 + ...programming-exercise-update.component.html | 8 +- .../programming-exercise-update.component.ts | 95 +++- ...e-instructor-exercise-sharing.component.ts | 77 +++ .../app/sharing/search-result-dto.model.ts | 72 +++ .../webapp/app/sharing/sharing.component.html | 103 ++++ .../webapp/app/sharing/sharing.component.ts | 120 +++++ src/main/webapp/app/sharing/sharing.model.ts | 71 +++ src/main/webapp/app/sharing/sharing.module.ts | 12 + src/main/webapp/app/sharing/sharing.route.ts | 23 + src/main/webapp/app/sharing/sharing.scss | 262 ++++++++++ .../webapp/i18n/de/programmingExercise.json | 6 +- src/main/webapp/i18n/de/sharing.json | 13 + .../webapp/i18n/en/programmingExercise.json | 15 +- src/main/webapp/i18n/en/sharing.json | 13 + 43 files changed, 2583 insertions(+), 38 deletions(-) create mode 100644 .idea/runConfigurations/Artemis_Dev.xml create mode 100644 src/main/java/de/tum/cit/aet/artemis/core/dto/SharingInfoDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/core/web/SharingSupportResource.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/ProgrammingExerciseImportFromSharingService.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingConnectorService.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingException.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingPluginService.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/exercise/web/ExerciseSharingResource.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/sharing/ExerciseSharingService.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/sharing/SharingMultipartZipFile.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/sharing/SharingSetupInfo.java create mode 100644 src/main/resources/config/application-sharing.yml create mode 100644 src/main/webapp/app/exercises/programming/manage/services/programming-exercise-sharing.service.ts create mode 100644 src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-instructor-exercise-sharing.component.ts create mode 100644 src/main/webapp/app/sharing/search-result-dto.model.ts create mode 100644 src/main/webapp/app/sharing/sharing.component.html create mode 100644 src/main/webapp/app/sharing/sharing.component.ts create mode 100644 src/main/webapp/app/sharing/sharing.model.ts create mode 100644 src/main/webapp/app/sharing/sharing.module.ts create mode 100644 src/main/webapp/app/sharing/sharing.route.ts create mode 100644 src/main/webapp/app/sharing/sharing.scss create mode 100644 src/main/webapp/i18n/de/sharing.json create mode 100644 src/main/webapp/i18n/en/sharing.json diff --git a/.idea/runConfigurations/Artemis_Dev.xml b/.idea/runConfigurations/Artemis_Dev.xml new file mode 100644 index 000000000000..de845ed85850 --- /dev/null +++ b/.idea/runConfigurations/Artemis_Dev.xml @@ -0,0 +1,18 @@ + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 4091d7584bc7..32b73090c862 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,7 @@ spotless { "src/test/java/**/*.java", ) exclude( + "**/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java", "**/src/test/resources/test-data/repository-export/EncodingISO_8559_1.java", "**/node_modules/**", "**/out/**", @@ -217,6 +218,7 @@ repositories { dependencies { + implementation group: 'org.codeability', name: 'SharingPluginPlatformAPI', version: '1.0.2' // Note: jenkins-client is not well maintained and includes dependencies to libraries with critical security issues (e.g. CVE-2020-10683 for dom4j@1.6.1) // implementation "com.offbytwo.jenkins:jenkins-client:0.3.8" implementation files("libs/jenkins-client-0.4.1.jar") diff --git a/docker/artemis-dev-local-vc-local-ci-mysql.yml b/docker/artemis-dev-local-vc-local-ci-mysql.yml index 25f62aaf048c..e7d0e8a6c00f 100644 --- a/docker/artemis-dev-local-vc-local-ci-mysql.yml +++ b/docker/artemis-dev-local-vc-local-ci-mysql.yml @@ -19,6 +19,7 @@ services: ports: - "8080:8080" - "5005:5005" # Java Remote Debugging port declared in the java cmd options + - "22:7921" # expose the port to make it reachable docker internally even if the external port mapping changes expose: - "5005" diff --git a/docker/artemis.yml b/docker/artemis.yml index 0c857a2b39ba..cca05c879134 100644 --- a/docker/artemis.yml +++ b/docker/artemis.yml @@ -19,6 +19,7 @@ services: # either add them in the environments or env_file section (alternative to application-local.yml) env_file: - ./artemis/config/prod.env + - ./.env # if you need to use another port than 8080 or one fixed port for all artemis-app containers in the future # you will probably not be able to override this setting outside the artemis.yml # as stated in the docker compose docs (at least not when this was committed) diff --git a/docker/artemis/config/dev-local-vc-local-ci.env b/docker/artemis/config/dev-local-vc-local-ci.env index e9099fa60bd9..63a2dc479949 100644 --- a/docker/artemis/config/dev-local-vc-local-ci.env +++ b/docker/artemis/config/dev-local-vc-local-ci.env @@ -2,7 +2,7 @@ # https://docs.artemis.cit.tum.de/dev/setup.html#debugging-with-docker _JAVA_OPTIONS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -SPRING_PROFILES_ACTIVE: artemis,scheduling,localci,localvc,buildagent,core,dev,docker +SPRING_PROFILES_ACTIVE: artemis,scheduling,localci,localvc,buildagent,core,dev,docker,sharing # Integrated Code Lifecycle settings with Jira ARTEMIS_USERMANAGEMENT_USEEXTERNAL="false" diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java index 274b9e393ecb..c81961ceebcd 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java @@ -385,9 +385,24 @@ public final class Constants { public static final int PUSH_NOTIFICATION_VERSION = 1; /** - * The value of the version field we send with each push notification to the native clients (Android & iOS). + * sharing configution resource path for sharing config request + */ + public static final String SHARINGCONFIG_RESOURCE_PATH = "/sharing/config"; + + /** + * sharing configution resource path for sharing config import request + */ + public static final String SHARINGIMPORT_RESOURCE_PATH = "/sharing/import"; + + /** + * sharing configution resource path for sharing config export request + */ + public static final String SHARINGEXPORT_RESOURCE_PATH = "/sharing/export"; + + /** + * sharing configution resource path for rest request, iff sharing profile is enabled */ - public static final int PUSH_NOTIFICATION_MINOR_VERSION = 2; + public static final String SHARINGCONFIG_RESOURCE_IS_ENABLED = SHARINGCONFIG_RESOURCE_PATH + "/is-enabled"; /** * The directory in the docker container in which the build script is executed diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java index fbd88e0b323b..c640d49a68d2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java @@ -208,6 +208,8 @@ public SecurityFilterChain filterChain(HttpSecurity http, SecurityProblemSupport .requestMatchers("/websocket/**").permitAll() .requestMatchers("/.well-known/jwks.json").permitAll() .requestMatchers("/.well-known/assetlinks.json").permitAll() + // sharing is protected by explicit security tokens, thus we can permitAll here + .requestMatchers("/api/sharing/**").permitAll() // Prometheus endpoint protected by IP address. .requestMatchers("/management/prometheus/**").access((authentication, context) -> new AuthorizationDecision(monitoringIpAddresses.contains(context.getRequest().getRemoteAddr()))); diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/SharingInfoDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/SharingInfoDTO.java new file mode 100644 index 000000000000..3481c9a76c71 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/SharingInfoDTO.java @@ -0,0 +1,102 @@ +package de.tum.cit.aet.artemis.core.dto; + +import java.util.Objects; + +import org.springframework.context.annotation.Profile; + +/** + * the sharing info to request an specific exercise from the sharing platform. + */ +@Profile("sharing") +public class SharingInfoDTO { + + /** + * the (random) basket Token + */ + private String basketToken; + + /** + * the callback URL for the basket request + */ + private String returnURL; + + /** + * the base URL for the basket request + */ + private String apiBaseURL; + + /** + * the index of the request exercise + */ + private int exercisePosition; + + /** + * the callback URL for the basket request + */ + public String getReturnURL() { + return returnURL; + } + + /** + * sets the callback URL for the basket request + */ + public void setReturnURL(String returnURL) { + this.returnURL = returnURL; + } + + /** + * the base URL for the basket request + */ + public String getApiBaseURL() { + return apiBaseURL; + } + + /** + * sets the base URL for the basket request + */ + public void setApiBaseURL(String apiBaseURL) { + this.apiBaseURL = apiBaseURL; + } + + /** + * the (random) basket Token + */ + public String getBasketToken() { + return basketToken; + } + + /** + * sets the basket Token + */ + public void setBasketToken(String basketToken) { + this.basketToken = basketToken; + } + + /** + * the index of the request exercise + */ + public int getExercisePosition() { + return exercisePosition; + } + + /** + * sets the index of the request exercise + */ + public void setExercisePosition(int exercisePosition) { + this.exercisePosition = exercisePosition; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + SharingInfoDTO that = (SharingInfoDTO) o; + return exercisePosition == that.exercisePosition && Objects.equals(basketToken, that.basketToken) && Objects.equals(returnURL, that.returnURL) + && Objects.equals(apiBaseURL, that.apiBaseURL); + } + + @Override + public int hashCode() { + return Objects.hash(basketToken, returnURL, apiBaseURL, exercisePosition); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java index e142d9f82026..2b1c73d6fa6f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/ProfileService.java @@ -130,4 +130,13 @@ public boolean isLtiActive() { public boolean isProductionActive() { return isProfileActive(JHipsterConstants.SPRING_PROFILE_PRODUCTION); } + + /** + * Checks if the sharing profile is active + * + * @return true if the sharing profile is active, false otherwise + */ + public boolean isSharing() { + return isProfileActive("sharing"); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/SharingSupportResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/SharingSupportResource.java new file mode 100644 index 000000000000..53a22a896fd8 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/SharingSupportResource.java @@ -0,0 +1,98 @@ +package de.tum.cit.aet.artemis.core.web; + +import static de.tum.cit.aet.artemis.core.config.Constants.SHARINGCONFIG_RESOURCE_IS_ENABLED; +import static de.tum.cit.aet.artemis.core.config.Constants.SHARINGCONFIG_RESOURCE_PATH; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.Optional; + +import org.codeability.sharing.plugins.api.SharingPluginConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.exercise.service.sharing.SharingConnectorService; + +/** + * REST controller for the exchange of configuration data between artemis and the sharing platform. + */ +@Validated +@RestController +@RequestMapping("/api") +@Profile("sharing") +public class SharingSupportResource { + + /** + * the logger + */ + private final Logger log = LoggerFactory.getLogger(SharingSupportResource.class); + + /** + * the sharing plugin service + */ + private final SharingConnectorService sharingConnectorService; + + /** + * constructor + * + * @param sharingConnectorService the sharing connector service + */ + @SuppressWarnings("unused") + public SharingSupportResource(SharingConnectorService sharingConnectorService) { + this.sharingConnectorService = sharingConnectorService; + } + + /** + * Returns Sharing Plugin configuration to be used in context with Artemis. + * This configuration is requested by the sharing platform on a regular basis. + * It is secured by the common secret api key token transferred by Authorization header. + * + * @param sharingApiKey the common secret api key token (transfered by Authorization header). + * @param apiBaseUrl the base url of the sharing application api (for callbacks) + * @param installationName a descriptive name of the sharing application + * + * @return Sharing Plugin configuration + * @see Connector Interface Setup + * + */ + @GetMapping(SHARINGCONFIG_RESOURCE_PATH) + public ResponseEntity getConfig(@RequestHeader("Authorization") Optional sharingApiKey, @RequestParam String apiBaseUrl, + @RequestParam String installationName) { + if (sharingApiKey.isPresent() && sharingConnectorService.validate(sharingApiKey.get())) { + log.info("Delivered Sharing Config "); + URL apiBaseUrl1; + try { + apiBaseUrl1 = URI.create(apiBaseUrl).toURL(); + } + catch (MalformedURLException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(null); + } + return ResponseEntity.ok(sharingConnectorService.getPluginConfig(apiBaseUrl1, installationName)); + } + log.warn("Received wrong or missing api key"); + return ResponseEntity.status(401).body(null); + } + + /** + * Return a boolean value representing the current state of Sharing + * + * @return Status 200 if a Sharing ApiBaseUrl is present, Status 503 otherwise + */ + @GetMapping(SHARINGCONFIG_RESOURCE_IS_ENABLED) + public ResponseEntity isSharingEnabled() { + if (sharingConnectorService.isSharingApiBaseUrlPresent()) { + return ResponseEntity.status(200).body(true); + } + return ResponseEntity.status(503).body(false); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/ProgrammingExerciseImportFromSharingService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/ProgrammingExerciseImportFromSharingService.java new file mode 100644 index 000000000000..3ad36f07ca91 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/ProgrammingExerciseImportFromSharingService.java @@ -0,0 +1,76 @@ +package de.tum.cit.aet.artemis.exercise.service.sharing; + +import java.io.IOException; +import java.net.URISyntaxException; + +import org.eclipse.jgit.api.errors.GitAPIException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseImportFromFileService; +import de.tum.cit.aet.artemis.sharing.ExerciseSharingService; +import de.tum.cit.aet.artemis.sharing.SharingMultipartZipFile; +import de.tum.cit.aet.artemis.sharing.SharingSetupInfo; + +/** + * Service for importing programming exercises from the sharing service. + */ +@Service +@Profile("sharing") +public class ProgrammingExerciseImportFromSharingService { + + /** + * the logger + */ + private final Logger log = LoggerFactory.getLogger(ProgrammingExerciseImportFromSharingService.class); + + /** + * the import from file service (because this service strongly relies in the file import format. + */ + private final ProgrammingExerciseImportFromFileService programmingExerciseImportFromFileService; + + /** + * the general exercise sharing service. + */ + private final ExerciseSharingService exerciseSharingService; + + /** + * the user repository + */ + private final UserRepository userRepository; + + /** + * constructor for spring initialization + * + * @param programmingExerciseImportFromFileService import from file services + * @param exerciseSharingService exercise sharing service + * @param userRepository user repository + */ + public ProgrammingExerciseImportFromSharingService(ProgrammingExerciseImportFromFileService programmingExerciseImportFromFileService, + ExerciseSharingService exerciseSharingService, UserRepository userRepository) { + this.programmingExerciseImportFromFileService = programmingExerciseImportFromFileService; + this.exerciseSharingService = exerciseSharingService; + this.userRepository = userRepository; + } + + /** + * Imports a programming exercise from the Sharing platform. + * It reuses the implementation of ProgrammingExerciseImportFromFileService for importing the exercise from a Zip file. + * + * @param sharingSetupInfo Containing sharing and exercise data needed for the import + */ + public ProgrammingExercise importProgrammingExerciseFromSharing(SharingSetupInfo sharingSetupInfo) throws SharingException, IOException, GitAPIException, URISyntaxException { + SharingMultipartZipFile zipFile = exerciseSharingService.getCachedBasketItem(sharingSetupInfo.getSharingInfo()); + User user = userRepository.getUserWithGroupsAndAuthorities(); + + if (sharingSetupInfo.getExercise().getCourseViaExerciseGroupOrCourseMember() == null) { + sharingSetupInfo.getExercise().setCourse(sharingSetupInfo.getCourse()); + } + return this.programmingExerciseImportFromFileService.importProgrammingExerciseFromFile(sharingSetupInfo.getExercise(), zipFile, sharingSetupInfo.getCourse(), user, true); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingConnectorService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingConnectorService.java new file mode 100644 index 000000000000..162b14beb4b0 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingConnectorService.java @@ -0,0 +1,174 @@ +package de.tum.cit.aet.artemis.exercise.service.sharing; + +import java.net.URL; +import java.util.concurrent.Executors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.codeability.sharing.plugins.api.SharingPluginConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import de.tum.cit.aet.artemis.core.service.ProfileService; + +/** + * Service to support Sharing Platform functionality as a plugin. + * + * @see Plugin Tutorial + */ +@Service +@Profile("sharing") +public class SharingConnectorService { + + private final Logger log = LoggerFactory.getLogger(SharingConnectorService.class); + + /** + * Base url for callbacks + */ + private URL sharingApiBaseUrl = null; + + /** + * installation name for Sharing Platform + */ + private String installationName = null; + + /** + * the shared secret api key + */ + @Value("${artemis.sharing.api-key:#{null}}") + private String sharingApiKey; + + /** + * the url of the sharing platform. + * Only needed for initial trigger an configuration exchange during startup. + */ + @Value("${artemis.sharing.server-url:#{null}}") + private String sharingUrl; + + /** + * installation name for Sharing Platform + */ + public String getInstallationName() { + return installationName; + } + + /** + * profile service + */ + private final ProfileService profileService; + + /** + * rest template for connector request + */ + private final RestTemplate restTemplate; + + public SharingConnectorService(ProfileService profileService, RestTemplate restTemplate) { + this.profileService = profileService; + this.restTemplate = restTemplate; + } + + /** + * Used to set the Sharing ApiBaseUrl to a new one + * + * @param sharingApiBaseUrl the new url + */ + public void setSharingApiBaseUrl(URL sharingApiBaseUrl) { + this.sharingApiBaseUrl = sharingApiBaseUrl; + } + + /** + * Get the Sharing ApiBaseUrl if any, else Null + * + * @return SharingApiBaseUrl or Null + */ + public URL getSharingApiBaseUrlOrNull() { + return sharingApiBaseUrl; + } + + /** + * Used to set Sharing ApiKey to a new one + * + * @param sharingApiKey the new ApiKey + */ + public void setSharingApiKey(String sharingApiKey) { + this.sharingApiKey = sharingApiKey; + } + + /** + * Get Sharing ApiKey if any has been set, else Null + * + * @return SharingApiKey or null + */ + public String getSharingApiKeyOrNull() { + return sharingApiKey; + } + + /** + * Method used to check if a Sharing ApiBaseUrl is present + */ + public boolean isSharingApiBaseUrlPresent() { + return this.profileService.isSharing() && sharingApiBaseUrl != null; + } + + /** + * Returns a sharing plugin configuration. + * + * @param apiBaseUrl the base url of the sharing application api (for callbacks) + * @param installationName a descriptive name of the sharing application + */ + public SharingPluginConfig getPluginConfig(URL apiBaseUrl, String installationName) { + this.sharingApiBaseUrl = apiBaseUrl; + this.installationName = installationName; + SharingPluginConfig.Action action = new SharingPluginConfig.Action("Import", "/sharing/import", "Export to Artemis", + "metadata.format.stream().anyMatch(entry->entry=='artemis' || entry=='Artemis').get()"); + return new SharingPluginConfig("Artemis Sharing Connector", new SharingPluginConfig.Action[] { action }); + } + + /** + * Method used to validate the given authorizaion apiKey from Sharing + * + * @param apiKey the Key to validate + * @return true if valid, false otherwise + */ + public boolean validate(String apiKey) { + Pattern p = Pattern.compile("Bearer\\s*(.+)"); + Matcher m = p.matcher(apiKey); + if (m.matches()) { + apiKey = m.group(1); + } + + return sharingApiKey.equals(apiKey); + } + + /** + * At (spring) application startup, we request a reinitialization of the sharing platform . + * It starts a background thread in order not to block application startup. + */ + @EventListener(ApplicationReadyEvent.class) + public void triggerSharingReinitAfterApplicationStart() { + // we have to trigger sharing plattform reinitialization asynchronously, + // because otherwise the main thread is blocked! + Executors.newFixedThreadPool(1).execute(this::triggerReinit); + } + + /** + * request a reinitialization of the sharing platform + */ + public void triggerReinit() { + if (sharingUrl != null) { + log.info("Requesting reinitialization from Sharing Platform"); + String reInitUrlWithApiKey = UriComponentsBuilder.fromHttpUrl(sharingUrl).pathSegment("api", "pluginIF", "v0.1", "reInitialize").queryParam("apiKey", sharingApiKey) + .encode().toUriString(); + + restTemplate.getForObject(reInitUrlWithApiKey, Boolean.class); + } + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingException.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingException.java new file mode 100644 index 000000000000..77f6435172b6 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingException.java @@ -0,0 +1,42 @@ +package de.tum.cit.aet.artemis.exercise.service.sharing; + +import java.io.Serial; + +import org.springframework.context.annotation.Profile; + +/** + * Sharing Exception during import or export to sharing platform. + */ +@Profile("sharing") +public class SharingException extends Exception { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * Creates a SharingException for connection failures. + */ + public SharingException(String message) { + super(message); + } + + /** + * Creates a SharingException for connection failures. + * + * @param cause The underlying cause + */ + public SharingException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Creates a SharingException for connection failures. + * + * @param endpoint The endpoint that failed + * @param cause The underlying cause + * @return A new SharingException + */ + public static SharingException connectionError(String endpoint, Throwable cause) { + return new SharingException("Failed to connect to sharing platform at " + endpoint, cause); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingPluginService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingPluginService.java new file mode 100644 index 000000000000..277951aab02a --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/sharing/SharingPluginService.java @@ -0,0 +1,152 @@ +package de.tum.cit.aet.artemis.exercise.service.sharing; + +import java.net.URL; +import java.util.concurrent.Executors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.codeability.sharing.plugins.api.SharingPluginConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import de.tum.cit.aet.artemis.core.service.ProfileService; + +/** + * Service to support Sharing Platform functionality as a plugin. + * + * @see Plugin Tutorial + */ +@SuppressWarnings("unused") +@Service +@Profile("sharing") +public class SharingPluginService { + + private final Logger log = LoggerFactory.getLogger(SharingPluginService.class); + + /** + * Base url for callbacks + */ + private URL sharingApiBaseUrl = null; + + /** + * installation name for Sharing Platform + */ + private String installationName = null; + + @Value("${artemis.sharing.api-key:#{null}}") + private String sharingApiKey; + + @Value("${artemis.sharing.server-url:#{null}}") + private String sharingUrl; + + public String getInstallationName() { + return installationName; + } + + private final ProfileService profileService; + + private final RestTemplate restTemplate; + + public SharingPluginService(ProfileService profileService, RestTemplate restTemplate) { + this.profileService = profileService; + this.restTemplate = restTemplate; + } + + /** + * Used to set the Sharing ApiBaseUrl to a new one + * + * @param sharingApiBaseUrl the new url + */ + public void setSharingApiBaseUrl(URL sharingApiBaseUrl) { + this.sharingApiBaseUrl = sharingApiBaseUrl; + } + + /** + * Get the Sharing ApiBaseUrl if any, else Null + * + * @return SharingApiBaseUrl or Null + */ + public URL getSharingApiBaseUrlOrNull() { + return sharingApiBaseUrl; + } + + /** + * Used to set Sharing ApiKey to a new one + * + * @param sharingApiKey the new ApiKey + */ + public void setSharingApiKey(String sharingApiKey) { + this.sharingApiKey = sharingApiKey; + } + + /** + * Get Sharing ApiKey if any has been set, else Null + * + * @return SharingApiKey or null + */ + public String getSharingApiKeyOrNull() { + return sharingApiKey; + } + + /** + * Method used to check if a Sharing ApiBaseUrl is present + */ + public boolean isSharingApiBaseUrlPresent() { + return this.profileService.isSharing() && sharingApiBaseUrl != null; + } + + /** + * Returns a sharing plugin configuration. + * + * @param apiBaseUrl the base url of the sharing application api (for callbacks) + * @param installationName a descriptive name of the sharing application + */ + public SharingPluginConfig getPluginConfig(URL apiBaseUrl, String installationName) { + this.sharingApiBaseUrl = apiBaseUrl; + this.installationName = installationName; + SharingPluginConfig.Action action = new SharingPluginConfig.Action("Import", "/sharing/import", "Export to Artemis", + "metadata.format.stream().anyMatch(entry->entry=='artemis' || entry=='Artemis').get()"); + return new SharingPluginConfig("Artemis Sharing Connector", new SharingPluginConfig.Action[] { action }); + } + + /** + * Method used to validate the given authorization apiKey from Sharing + * + * @param apiKey the Key to validate + * @return true if valid, false otherwise + */ + public boolean validate(String apiKey) { + Pattern p = Pattern.compile("Bearer\\s*(.+)"); + Matcher m = p.matcher(apiKey); + if (m.matches()) { + apiKey = m.group(1); + } + + return sharingApiKey.equals(apiKey); + } + + @EventListener(ApplicationReadyEvent.class) + public void triggerSharingReinitAfterApplicationStart() { + // we have to trigger sharing plattform reinitialization asynchronously, + // because otherwise the main thread is blocked! + Executors.newFixedThreadPool(1).execute(this::triggerReinit); + } + + public void triggerReinit() { + if (sharingUrl != null) { + log.info("Requesting reinitialization from Sharing Platform"); + String reInitUrlWithApiKey = UriComponentsBuilder.fromHttpUrl(sharingUrl).pathSegment("api", "pluginIF", "v0.1", "reInitialize").queryParam("apiKey", sharingApiKey) + .encode().toUriString(); + + restTemplate.getForObject(reInitUrlWithApiKey, Boolean.class); + } + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ExerciseSharingResource.java b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ExerciseSharingResource.java new file mode 100644 index 000000000000..4332bca36870 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ExerciseSharingResource.java @@ -0,0 +1,191 @@ +package de.tum.cit.aet.artemis.exercise.web; + +import static de.tum.cit.aet.artemis.core.config.Constants.SHARINGEXPORT_RESOURCE_PATH; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.core.UriBuilder; + +import org.codeability.sharing.plugins.api.ShoppingBasket; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.dto.SharingInfoDTO; +import de.tum.cit.aet.artemis.exercise.service.sharing.ProgrammingExerciseImportFromSharingService; +import de.tum.cit.aet.artemis.exercise.service.sharing.SharingException; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.sharing.ExerciseSharingService; +import de.tum.cit.aet.artemis.sharing.SharingSetupInfo; +import tech.jhipster.web.util.ResponseUtil; + +/** + * REST controller for managing the sharing of programming exercises. + */ +@RestController +@RequestMapping("/api") +@Profile("sharing") +public class ExerciseSharingResource { + + /** + * a logger + */ + private final Logger log = LoggerFactory.getLogger(ExerciseSharingResource.class); + + /** + * the exercise sharing service + */ + private final ExerciseSharingService exerciseSharingService; + + /** + * the programming-exercise import from Sharing Service + */ + private final ProgrammingExerciseImportFromSharingService programmingExerciseImportFromSharingService; + + /** + * constuctor for spring + * + * @param exerciseSharingService the sharing service + * @param programmingExerciseImportFromSharingService programming exercise import from sharing service + */ + public ExerciseSharingResource(ExerciseSharingService exerciseSharingService, ProgrammingExerciseImportFromSharingService programmingExerciseImportFromSharingService) { + this.exerciseSharingService = exerciseSharingService; + + this.programmingExerciseImportFromSharingService = programmingExerciseImportFromSharingService; + } + + /** + * GET .../sharing-import/basket + * + * @return the ResponseEntity with status 200 (OK) and with body the Shopping Basket, or with status 404 (Not Found) + */ + @GetMapping("/sharing/import/basket") + public ResponseEntity loadShoppingBasket(@RequestParam String basketToken, @RequestParam String apiBaseUrl) { + Optional sharingInfoDTO = exerciseSharingService.getBasketInfo(basketToken, apiBaseUrl); + return ResponseUtil.wrapOrNotFound(sharingInfoDTO); + } + + /** + * GET .../sharing/setup-import + * + * @return the ResponseEntity with status 200 (OK) and with body the programming exercise, or with status 404 (Not Found) + */ + @PostMapping("/sharing/setup-import") + public ResponseEntity setUpFromSharingImport(@RequestBody SharingSetupInfo sharingSetupInfo) + throws GitAPIException, SharingException, IOException, URISyntaxException { + ProgrammingExercise exercise = programmingExerciseImportFromSharingService.importProgrammingExerciseFromSharing(sharingSetupInfo); + return ResponseEntity.ok().body(exercise); + } + + /** + * GET .../sharing/import/basket/problemStatement : get problem statement of the exercise defined in sharingInfo. + * + * @param sharingInfo the sharing info (with exercise position in basket) + * @return the ResponseEntity with status 200 (OK) and with body the problem statement, or with status 404 (Not Found) + */ + @PostMapping("/sharing/import/basket/problemStatement") + public ResponseEntity getProblemStatement(@RequestBody SharingInfoDTO sharingInfo) throws IOException { + String problemStatement = this.exerciseSharingService.getProblemStatementFromBasket(sharingInfo); + return ResponseEntity.ok().body(problemStatement); + } + + /** + * GET .../sharing/import/basket/exerciseDetails : get exercise details of the exercise defined in sharingInfo. + * TODO: why seems result identical to getProblemStatement? + * + * @param sharingInfo the sharing info (with exercise position in basket) + * @return the ResponseEntity with status 200 (OK) and with body the problem statement, or with status 404 (Not Found) + */ + @PostMapping("/sharing/import/basket/exerciseDetails") + public ResponseEntity getExerciseDetails(@RequestBody SharingInfoDTO sharingInfo) throws IOException { + String exerciseDetails = this.exerciseSharingService.getExerciseDetailsFromBasket(sharingInfo); + return ResponseEntity.ok().body(org.apache.commons.text.StringEscapeUtils.escapeHtml4(exerciseDetails)); + } + + /** + * POST /sharing/export/{exerciseId}: export programming exercise to sharing + * by generating a unique URL token exposing the exercise + * + * @param exerciseId the id of the exercise to export + * @return the URL to Sharing + */ + @PostMapping(SHARINGEXPORT_RESOURCE_PATH + "/{exerciseId}") + @PreAuthorize("hasAnyRole('INSTRUCTOR', 'ADMIN')") + public ResponseEntity exportExerciseToSharing(HttpServletResponse response, @RequestBody String callBackUrl, @PathVariable("exerciseId") Long exerciseId) { + try { + URI uriRedirect = exerciseSharingService.exportExerciseToSharing(exerciseId).toURI(); + uriRedirect = UriBuilder.fromUri(uriRedirect).queryParam("callBack", callBackUrl).build(); + return ResponseEntity.ok().body("\"" + uriRedirect.toString() + "\""); + } + catch (SharingException | URISyntaxException e) { + return ResponseEntity.internalServerError().body(e.getMessage()); + } + + } + + /** + * GET /sharing/export/{exerciseToken}: Endpoint exposing an exported exercise zip to Sharing + * + * @param token in base64 format and used to retrieve the exercise + * @return a stream of the zip file + * @throws FileNotFoundException if zip file does not exist any more + */ + @GetMapping(SHARINGEXPORT_RESOURCE_PATH + "/{token}") + // Custom Key validation is applied + public ResponseEntity exportExerciseToSharing(@PathVariable("token") String token, @RequestParam("sec") String sec) throws FileNotFoundException { + if (sec.isEmpty() || !exerciseSharingService.validate(token, sec)) { + return ResponseEntity.status(401).body(null); + } + File zipFile = exerciseSharingService.getExportedExerciseByToken(token); + + if (zipFile == null) { + return ResponseEntity.notFound().build(); + } + + /* + * Customized FileInputStream to delete and therefore clean up the returned files + */ + class NewFileInputStream extends FileInputStream { + + final File file; + + public NewFileInputStream(@NotNull File file) throws FileNotFoundException { + super(file); + this.file = file; + } + + public void close() throws IOException { + super.close(); + if (!file.delete()) { + log.warn("Could not delete imported file from Sharing-Platform"); + } + } + } + + InputStreamResource resource = new InputStreamResource(new NewFileInputStream(zipFile)); + + return ResponseEntity.ok().contentLength(zipFile.length()).contentType(MediaType.valueOf("application/zip")).header("filename", zipFile.getName()).body(resource); + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportFromFileService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportFromFileService.java index 784e29599185..3a23fa60fb66 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportFromFileService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportFromFileService.java @@ -3,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import static de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseExportService.BUILD_PLAN_FILE_NAME; +import java.io.FileWriter; import java.io.IOException; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; @@ -14,6 +15,9 @@ import java.util.Map; import java.util.stream.Stream; +import javax.json.JsonObject; +import javax.json.JsonString; + import org.apache.commons.io.FileUtils; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.filefilter.NameFileFilter; @@ -42,6 +46,9 @@ import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; import de.tum.cit.aet.artemis.programming.repository.BuildPlanRepository; +/** + * services to read exercises from a (zip-file) + */ @Profile(PROFILE_CORE) @Service public class ProgrammingExerciseImportFromFileService { @@ -88,10 +95,11 @@ public ProgrammingExerciseImportFromFileService(ProgrammingExerciseService progr * @param zipFile the zip file that contains the exercise * @param course the course to which the exercise should be added * @param user the user initiating the import + * @param isImportFromSharing flag whether file import (false) of sharing import * @return the imported programming exercise **/ - public ProgrammingExercise importProgrammingExerciseFromFile(ProgrammingExercise originalProgrammingExercise, MultipartFile zipFile, Course course, User user) - throws IOException, GitAPIException, URISyntaxException { + public ProgrammingExercise importProgrammingExerciseFromFile(ProgrammingExercise originalProgrammingExercise, MultipartFile zipFile, Course course, User user, + boolean isImportFromSharing) throws IOException, GitAPIException, URISyntaxException { if (!"zip".equals(FilenameUtils.getExtension(zipFile.getOriginalFilename()))) { throw new BadRequestAlertException("The file is not a zip file", "programmingExercise", "fileNotZip"); } @@ -101,9 +109,34 @@ public ProgrammingExercise importProgrammingExerciseFromFile(ProgrammingExercise importExerciseDir = Files.createTempDirectory("imported-exercise-dir"); Path exerciseFilePath = Files.createTempFile(importExerciseDir, "exercise-for-import", ".zip"); + if (isImportFromSharing) { + // Exercises from Sharing are currently exported in a different zip structure containing an additional dir + try (Stream walk = Files.walk(importExerciseDir)) { + importExerciseDir = walk.filter(Files::isDirectory).toList().getFirst(); + } + } + zipFile.transferTo(exerciseFilePath); zipFileService.extractZipFileRecursively(exerciseFilePath); checkRepositoriesExist(importExerciseDir); + + if (isImportFromSharing) { + // ACL + ObjectMapper mapper = new ObjectMapper(); + var exerciseJsonPath = retrieveExerciseJsonPath(importExerciseDir); + + JsonObject json = mapper.readValue(exerciseJsonPath.toFile(), JsonObject.class); + + if (json.get("type").toString().contains("programming")) { + json.put("type", new JsonStringImpl("programming")); + } + + // Write back into the file + try (FileWriter file = new FileWriter(exerciseJsonPath.toFile())) { + file.write(json.toString()); + } + } + var oldShortName = getProgrammingExerciseFromDetailsFile(importExerciseDir).getShortName(); programmingExerciseService.validateNewProgrammingExerciseSettings(originalProgrammingExercise, course); // TODO: creating the whole exercise (from template) is a bad solution in this case, we do not want the template content, instead we want the file content of the zip @@ -147,6 +180,14 @@ private void importBuildPlanIfExisting(ProgrammingExercise programmingExercise, } } + /** + * Overloaded method setting the isImportFromSharing flag to false as default + */ + public ProgrammingExercise importProgrammingExerciseFromFile(ProgrammingExercise programmingExerciseForImport, MultipartFile zipFile, Course course, User user) + throws IOException, GitAPIException, URISyntaxException { + return this.importProgrammingExerciseFromFile(programmingExerciseForImport, zipFile, course, user, false); + } + /** * Copy embedded files from the extracted zip file to the markdown folder, so they can be used in the problem statement * @@ -304,4 +345,32 @@ private Path retrieveExerciseJsonPath(Path dirPath) throws IOException { } return result.getFirst(); } + + /** + * just a dumb helper class to construct a simple json string. + * I'm happy to have a much simpler solution. + */ + private static class JsonStringImpl implements JsonString { + + final String s; + + private JsonStringImpl(String s) { + this.s = s; + } + + @Override + public String getString() { + return s; + } + + @Override + public CharSequence getChars() { + return s; + } + + @Override + public ValueType getValueType() { + return ValueType.STRING; + } + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/sharing/ExerciseSharingService.java b/src/main/java/de/tum/cit/aet/artemis/sharing/ExerciseSharingService.java new file mode 100644 index 000000000000..a61b2a372e64 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/sharing/ExerciseSharingService.java @@ -0,0 +1,455 @@ +package de.tum.cit.aet.artemis.sharing; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.ResponseProcessingException; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.MediaType; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.http.client.utils.URIBuilder; +import org.codeability.sharing.plugins.api.ShoppingBasket; +import org.glassfish.jersey.client.ClientConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.util.StreamUtils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; + +import de.tum.cit.aet.artemis.core.dto.SharingInfoDTO; +import de.tum.cit.aet.artemis.core.service.ProfileService; +import de.tum.cit.aet.artemis.exercise.service.sharing.SharingConnectorService; +import de.tum.cit.aet.artemis.exercise.service.sharing.SharingException; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; +import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseExportService; + +/** + * service for sharing exercises via the sharing platform. + */ +@Service +@Profile("sharing") +public class ExerciseSharingService { + + /** + * the logger + */ + private final Logger log = LoggerFactory.getLogger(ExerciseSharingService.class); + + /** + * the repo download path + */ + @Value("${artemis.repo-download-clone-path}") + private String repoDownloadClonePath; + + /** + * the artemis server url + */ + @Value("${server.url}") + protected String artemisServerUrl; + + /** + * the profile service + */ + protected ProfileService profileService; + + /** + * the programming Exercise Export Service + */ + private final ProgrammingExerciseExportService programmingExerciseExportService; + + /** + * the sharing connector service + */ + private final SharingConnectorService sharingConnectorService; + + /** + * the programming exercise repository + */ + private final ProgrammingExerciseRepository programmingExerciseRepository; + + /** + * constructor for spring + * + * @param programmingExerciseExportService programming exercise export service + * @param sharingConnectorService sharing connector service + * @param programmingExerciseRepository programming exercise repository + * @param profileService profile service + */ + public ExerciseSharingService(ProgrammingExerciseExportService programmingExerciseExportService, SharingConnectorService sharingConnectorService, + ProgrammingExerciseRepository programmingExerciseRepository, ProfileService profileService) { + this.programmingExerciseExportService = programmingExerciseExportService; + this.sharingConnectorService = sharingConnectorService; + this.programmingExerciseRepository = programmingExerciseRepository; + this.profileService = profileService; + } + + /** + * loads the basket info from the sharing platform + * + * @param basketToken the basket token + * @param apiBaseUrl the url + * @return an optional shopping basket + */ + public Optional getBasketInfo(String basketToken, String apiBaseUrl) { + ClientConfig restClientConfig = new ClientConfig(); + restClientConfig.register(ShoppingBasket.class); + try (Client client = ClientBuilder.newClient(restClientConfig)) { + WebTarget target = client.target(correctLocalHostInDocker(apiBaseUrl).concat("/basket/").concat(basketToken)); + String response = target.request().accept(MediaType.APPLICATION_JSON).get(String.class); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.registerModule(new JavaTimeModule()); + + ShoppingBasket shoppingBasket = objectMapper.readValue(response, ShoppingBasket.class); + return Optional.ofNullable(shoppingBasket); + + } + catch (ResponseProcessingException rpe) { + log.warn("Unrecognized property when importing exercise from Sharing", rpe); + return Optional.empty(); + } + catch (JsonProcessingException e) { + log.error("Cannot parse properties: ", e); + } + return Optional.empty(); + } + + /** + * return an exercise from the basket (as zip file) + * + * @param sharingInfo the sharing info + * @param itemPosition the item position + * @return an zip stream + * @throws SharingException if exercise cannot be loaded + */ + public Optional getBasketItem(SharingInfoDTO sharingInfo, int itemPosition) throws SharingException { + ClientConfig restClientConfig = new ClientConfig(); + restClientConfig.register(ShoppingBasket.class); + + try (Client client = ClientBuilder.newClient(restClientConfig)) { + WebTarget target = client.target(correctLocalHostInDocker(sharingInfo.getApiBaseURL()) + "/basket/" + sharingInfo.getBasketToken() + "/repository/" + itemPosition) + .queryParam("format", "artemis"); + InputStream zipInput = target.request().accept(MediaType.APPLICATION_OCTET_STREAM).get(InputStream.class); + + if (zipInput == null) { + throw new SharingException("Could not retrieve basket item"); + } + + SharingMultipartZipFile zipFileItem = new SharingMultipartZipFile(getBasketFileName(sharingInfo.getBasketToken(), itemPosition), zipInput); + return Optional.of(zipFileItem); + } + catch (WebApplicationException wae) { + throw new SharingException("Could not retrieve basket item"); + } + } + + /** + * simple loading cache for file with 1 hour timeout. + */ + private final LoadingCache repositoryCache = CacheBuilder.newBuilder().maximumSize(10000).expireAfterAccess(1, TimeUnit.HOURS) + .removalListener(notification -> ((File) notification.getValue()).delete()).build(new CacheLoader<>() { + + public File load(SharingInfoDTO sharingInfo) { + try { + Optional basketItemO = getBasketItem(sharingInfo, sharingInfo.getExercisePosition()); + return basketItemO.map(basketItem -> { + try { + File fTemp = File.createTempFile("SharingBasket", ".zip"); + + StreamUtils.copy(basketItem.getInputStream(), new FileOutputStream(fTemp)); + return fTemp; + } + catch (IOException e) { + log.warn("Cannot load sharing Info", e); + return null; + } + }).orElse(null); + } + catch (SharingException e) { + log.warn("Cannot load sharing Info", e); + return null; + } + + } + }); + + public SharingMultipartZipFile getCachedBasketItem(SharingInfoDTO sharingInfo) throws IOException, SharingException { + int itemPosition = sharingInfo.getExercisePosition(); + File f = repositoryCache.getIfPresent(sharingInfo); + if (f != null) { + try { + return new SharingMultipartZipFile(getBasketFileName(sharingInfo.getBasketToken(), itemPosition), new FileInputStream(f)); + } + catch (FileNotFoundException e) { + log.warn("Cannot find cached file for {}:{} at {}", sharingInfo.getBasketToken(), itemPosition, f.getAbsoluteFile(), e); + } + } + // second try (first try in cache); + Optional basketItem = getBasketItem(sharingInfo, itemPosition); + return basketItem.orElse(null); + } + + /** + * this is just a weak implementation for local testing (within a docker). It replaces an localhost wit host.docker.internal. + * + * @param url the url to be corrected + * @return an url, that points to host.docker.internal if previously directed to localhost. + */ + private String correctLocalHostInDocker(String url) { + if (url.contains("//localhost") && profileService.isProfileActive("docker")) { + return url.replace("//localhost", "//host.docker.internal"); + } + return url; + } + + /** + * Retrieves the Problem-Statement file from a Sharing basket + * + * @param sharingInfo of the basket to extract the problem statement from + * @return The content of the Problem-Statement file + */ + public String getProblemStatementFromBasket(SharingInfoDTO sharingInfo) { + Pattern pattern = Pattern.compile("^Problem-Statement|^exercise.md$", Pattern.CASE_INSENSITIVE); + + try { + String problemStatement = this.getEntryFromBasket(pattern, sharingInfo); + // The Basket comes from the sharing platform, however the problem statement comes from a git repository. + // A malicious user manipulate the problem statement, and insert malicious code. + return Objects.requireNonNullElse(org.springframework.web.util.HtmlUtils.htmlEscape(problemStatement), "No Problem Statement found!"); + } + catch (Exception e) { + throw new NotFoundException("Could not retrieve problem statement from imported exercise"); + } + } + + /** + * TODO: check usage + * Retrieves the Exercise-Details file from a Sharing basket + * + * @param sharingInfo of the basket to extract the problem statement from + * @return The content of the Exercise-Details file + */ + public String getExerciseDetailsFromBasket(SharingInfoDTO sharingInfo) { + Pattern pattern = Pattern.compile("^Exercise-Details", Pattern.CASE_INSENSITIVE); + + try { + String problemStatement = this.getEntryFromBasket(pattern, sharingInfo); + return Objects.requireNonNullElse(problemStatement, "No Problem Statement found!"); + } + catch (Exception e) { + throw new NotFoundException("Could not retrieve exercise details from imported exercise"); + } + } + + /** + * Retrieves an entry from a given Sharing basket, basing on the given RegEx. + * If nothing is found, null is returned. + * + * @param matchingPattern RegEx matching the entry to return. + * @param sharingInfo of the basket to retrieve the entry from + * @return The content of the entry, or null if not found. + * @throws IOException if a reading error occurs + */ + public String getEntryFromBasket(Pattern matchingPattern, SharingInfoDTO sharingInfo) throws IOException { + InputStream repositoryStream; + try { + repositoryStream = this.getCachedBasketItem(sharingInfo).getInputStream(); + } + catch (IOException | SharingException e) { + log.error("Cannot read input Template for {}", sharingInfo.getBasketToken(), e); + return null; + } + + ZipInputStream zippedRepositoryStream = new ZipInputStream(repositoryStream); + + ZipEntry entry; + while ((entry = zippedRepositoryStream.getNextEntry()) != null) { + Matcher matcher = matchingPattern.matcher(entry.getName()); + if (matcher.find()) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buffer = new byte[102400]; + int bytesRead; + while ((bytesRead = zippedRepositoryStream.read(buffer)) != -1) { + baos.write(buffer, 0, bytesRead); + } + String entryContent = baos.toString(StandardCharsets.UTF_8); + baos.close(); + zippedRepositoryStream.closeEntry(); + return entryContent; + } + zippedRepositoryStream.closeEntry(); + } + return null; // Not found + } + + /** + * Creates Zip file for exercise and returns a URL pointing to Sharing + * with a callback URL addressing the generated Zip file for download + * + * @param exerciseId the ID of the exercise to export + * @return URL to sharing with a callback URL to the generated zip file + */ + public URL exportExerciseToSharing(Long exerciseId) throws SharingException { + if (!sharingConnectorService.isSharingApiBaseUrlPresent()) { + throw new SharingException("No Sharing ApiBaseUrl provided"); + } + try { + Optional exercise = programmingExerciseRepository.findWithPlagiarismDetectionConfigTeamConfigAndBuildConfigById(exerciseId); + + if (exercise.isEmpty()) { + throw new SharingException("Could not find exercise to export"); + } + + List exportErrors = new ArrayList<>(); + Path zipFilePath = programmingExerciseExportService.exportProgrammingExerciseForDownload(exercise.get(), exportErrors); + + if (!exportErrors.isEmpty()) { + String errorMessage = String.join(", ", exportErrors); + throw new SharingException("Could not generate Zip file to export: " + errorMessage); + } + + // remove the 'repoDownloadClonePath' part and 'zip' extension + String token = Path.of(repoDownloadClonePath).relativize(zipFilePath).toString().replace(".zip", ""); + String tokenInB64 = Base64.getEncoder().encodeToString(token.getBytes()).replaceAll("=+$", ""); + String tokenIntegrity = createHMAC(tokenInB64); + + URL apiBaseUrl = sharingConnectorService.getSharingApiBaseUrlOrNull(); + String sharingImportEndPoint = "/exercise/import"; + URIBuilder callBackBuilder = new URIBuilder(artemisServerUrl + "/api/sharing/export/" + tokenInB64); + callBackBuilder.addParameter("sec", tokenIntegrity); + URIBuilder builder = new URIBuilder(); + builder.setScheme(apiBaseUrl.getProtocol()).setHost(apiBaseUrl.getHost()).setPath(apiBaseUrl.getPath().concat(sharingImportEndPoint)).setPort(apiBaseUrl.getPort()) + .addParameter("exerciseUrl", callBackBuilder.build().toString()); + + return builder.build().toURL(); + } + catch (URISyntaxException e) { + log.error("An error occurred during URL creation: " + e.getMessage()); + return null; + } + catch (IOException e) { + log.error("Could not generate Zip file for export: " + e.getMessage()); + return null; + } + } + + /** + * just to secure token for integrity + * + * @param base64token the token (already base64 encoded + * @return returns HMAC-Hash + */ + private String createHMAC(String base64token) { + // Definiere die HMAC-Methode (z. B. HmacSHA256) + String algorithm = "HmacSHA256"; + String psk = sharingConnectorService.getSharingApiKeyOrNull(); + + // Konvertiere den Pre-shared Key in ein Byte-Array + SecretKeySpec secretKeySpec = new SecretKeySpec(psk.getBytes(), algorithm); + + try { + // Initialisiere den Mac mit dem Algorithmus und dem Schlüssel + Mac mac = Mac.getInstance(algorithm); + mac.init(secretKeySpec); + // Berechne das HMAC + byte[] hmacBytes = mac.doFinal(base64token.getBytes()); + + // Konvertiere das Ergebnis in Base64 für einfache Speicherung + return Base64.getEncoder().encodeToString(hmacBytes); + } + catch (NoSuchAlgorithmException | InvalidKeyException e) { + return Base64.getEncoder().encodeToString(new byte[] {}); + } + + } + + /** + * checks the integrity of the base64token + * + * @param base64token the base64token + * @param sec the hmac hash + * @return true, iff hash is correct + */ + public boolean validate(String base64token, String sec) { + String computedHMAC = createHMAC(base64token); + return computedHMAC.equals(sec); + } + + /** + * loads the stored file from file system (via the b64 token). + * + * @param b64Token the base64 encoded token + * @return the file referenced by the token + */ + public File getExportedExerciseByToken(String b64Token) { + if (!isValidToken(b64Token)) { + log.warn("Invalid token received: {}", b64Token); + return null; + } + String decodedToken = new String(Base64.getDecoder().decode(b64Token)); + Path parent = Paths.get(repoDownloadClonePath, decodedToken + ".zip"); + File exportedExercise = parent.toFile(); + if (exportedExercise.exists()) { + return exportedExercise; + } + else { + return null; + } + } + + private boolean isValidToken(String token) { + // Implement validation logic, e.g., check for illegal characters or patterns + return token.matches("^[a-zA-Z0-9_-]+$"); + } + + /** + * Returns a formatted filename for a basket file. + * + * @param basketToken of the retrieved file + * @param itemPosition of the retrieved file + */ + private String getBasketFileName(String basketToken, int itemPosition) { + return "sharingBasket" + basketToken + "-" + itemPosition + ".zip"; + } + +} diff --git a/src/main/java/de/tum/cit/aet/artemis/sharing/SharingMultipartZipFile.java b/src/main/java/de/tum/cit/aet/artemis/sharing/SharingMultipartZipFile.java new file mode 100644 index 000000000000..a85c22950615 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/sharing/SharingMultipartZipFile.java @@ -0,0 +1,82 @@ +package de.tum.cit.aet.artemis.sharing; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +import jakarta.validation.constraints.NotNull; + +import javax.annotation.Nonnull; + +import org.apache.commons.io.FileUtils; +import org.springframework.context.annotation.Profile; +import org.springframework.web.multipart.MultipartFile; + +/** + * just a utility class to hold the zip file from sharing + */ +@Profile("sharing") +public class SharingMultipartZipFile implements MultipartFile { + + private final String name; + + private final InputStream inputStream; + + public SharingMultipartZipFile(String name, InputStream inputStream) { + this.name = name; + this.inputStream = inputStream; + } + + @Override + @Nonnull + public String getName() { + return this.name; + } + + @Override + public String getOriginalFilename() { + return this.name; + } + + @Override + public String getContentType() { + return "application/zip"; + } + + @Override + public boolean isEmpty() { + try { + return this.inputStream.available() <= 0; + } + catch (IOException e) { + return true; // unreadable + } + } + + @Override + public long getSize() { + try { + return this.inputStream.available(); + } + catch (IOException e) { + return 0; // unreadable + } + } + + @Override + @Nonnull + public byte @NotNull [] getBytes() throws IOException { + return this.inputStream.readAllBytes(); + } + + @Override + @Nonnull + public @NotNull InputStream getInputStream() throws IOException { + return this.inputStream; + } + + @Override + public void transferTo(@NotNull File dest) throws IOException, IllegalStateException { + FileUtils.copyInputStreamToFile(this.inputStream, dest); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/sharing/SharingSetupInfo.java b/src/main/java/de/tum/cit/aet/artemis/sharing/SharingSetupInfo.java new file mode 100644 index 000000000000..8b7f559684e3 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/sharing/SharingSetupInfo.java @@ -0,0 +1,36 @@ +package de.tum.cit.aet.artemis.sharing; + +import org.springframework.context.annotation.Profile; + +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.dto.SharingInfoDTO; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; + +/** + * the sharing info, wrapping the original sharing Info from the sharing platform and adding course and exercise info. + */ +@Profile("sharing") +public record SharingSetupInfo(ProgrammingExercise exercise, Course course, SharingInfoDTO sharingInfo) { + + /** + * the exercise + */ + public ProgrammingExercise getExercise() { + return exercise; + } + + /** + * the course + */ + public Course getCourse() { + return course; + } + + /** + * the sharing info from the sharing platform + */ + public SharingInfoDTO getSharingInfo() { + return sharingInfo; + } + +} diff --git a/src/main/resources/config/application-dev.yml b/src/main/resources/config/application-dev.yml index 93543e7bbbfe..e4651c0c8607 100644 --- a/src/main/resources/config/application-dev.yml +++ b/src/main/resources/config/application-dev.yml @@ -60,7 +60,7 @@ spring: server: port: 8080 - url: https://artemislocal.ase.in.tum.de + url: http://localhost:8080 # =================================================================== # JHipster specific properties diff --git a/src/main/resources/config/application-sharing.yml b/src/main/resources/config/application-sharing.yml new file mode 100644 index 000000000000..64550fc83e87 --- /dev/null +++ b/src/main/resources/config/application-sharing.yml @@ -0,0 +1,15 @@ +# =================================================================== +# Local VC specific properties: this file will only be loaded during startup if the profile localvc is active +# +# This configuration overrides the application.yml file. +# =================================================================== +artemis: + sharing: + # the shared common secret + api-key: acdd-erdf-asd2-234f-234d-32eb + # an url used to force reinitialization of Sharing Plattform configuation + # if not set, you have to wait for the next scheduled reinitialization request (typically every 10 minutes) + server-url: http://localhost:9001 + + + diff --git a/src/main/webapp/app/app-routing.module.ts b/src/main/webapp/app/app-routing.module.ts index 6ab77a7e46d0..574b43ba68d4 100644 --- a/src/main/webapp/app/app-routing.module.ts +++ b/src/main/webapp/app/app-routing.module.ts @@ -183,6 +183,10 @@ const LAYOUT_ROUTES: Routes = [navbarRoute, ...errorRoute]; pathMatch: 'full', loadComponent: () => import('./iris/about-iris/about-iris.component').then((m) => m.AboutIrisComponent), }, + { + path: 'sharing/import/:basketToken', + loadChildren: () => import('./sharing/sharing.module').then((m) => m.SharingModule), + }, ], { enableTracing: false, onSameUrlNavigation: 'reload' }, ), diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.html b/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.html index 95da3dea7a71..8ced350af75a 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.html +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.html @@ -107,6 +107,7 @@

{{
@if (programmingExercise.isAtLeastInstructor) { + } @if (programmingExercise.isAtLeastEditor) { diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts index dd1c4ff747d7..f8db20205ceb 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts @@ -16,6 +16,7 @@ import { ExerciseType, IncludedInOverallScore } from 'app/entities/exercise.mode import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ConfirmAutofocusModalComponent } from 'app/shared/components/confirm-autofocus-modal.component'; import { TranslateService } from '@ngx-translate/core'; +import { ProgrammingExerciseSharingService } from 'app/exercises/programming/manage/services/programming-exercise-sharing.service'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { ExerciseManagementStatisticsDto } from 'app/exercises/shared/statistics/exercise-management-statistics-dto'; import { StatisticsService } from 'app/shared/statistics-graph/statistics.service'; @@ -116,6 +117,8 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { irisEnabled = false; irisChatEnabled = false; + isExportToSharingEnabled = false; + isAdmin = false; addedLineCount: number; removedLineCount: number; @@ -152,6 +155,7 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { private consistencyCheckService: ConsistencyCheckService, private irisSettingsService: IrisSettingsService, private aeolusService: AeolusService, + private sharingService: ProgrammingExerciseSharingService, ) {} ngOnInit() { @@ -257,6 +261,11 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { this.doughnutStats = statistics; }); }); + this.sharingService.isSharingEnabled().subscribe((isEnabled) => { + if (isEnabled.body) { + this.isExportToSharingEnabled = true; + } + }); } ngOnDestroy(): void { diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts index 19c51d8dff55..a3963fec41a7 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management-routing.module.ts @@ -46,6 +46,19 @@ export const routes: Routes = [ }, canActivate: [UserRouteAccessService], }, + { + path: ':courseId/programming-exercises/importSharing', + component: ProgrammingExerciseUpdateComponent, + resolve: { + programmingExercise: ProgrammingExerciseResolve, + }, + data: { + authorities: [Authority.TA, Authority.INSTRUCTOR, Authority.ADMIN], + usePathForBreadcrumbs: true, + pageTitle: 'artemisApp.programmingExercise.home.title', + }, + canActivate: [UserRouteAccessService], + }, { path: ':courseId/programming-exercises/:exerciseId/edit', component: ProgrammingExerciseUpdateComponent, @@ -82,6 +95,18 @@ export const routes: Routes = [ }, canActivate: [UserRouteAccessService], }, + { + path: ':courseId/programming-exercises/import-from-sharing', + component: ProgrammingExerciseUpdateComponent, + resolve: { + programmingExercise: ProgrammingExerciseResolve, + }, + data: { + authorities: [Authority.EDITOR, Authority.INSTRUCTOR, Authority.ADMIN], + pageTitle: 'artemisApp.programmingExercise.home.importLabel', + }, + canActivate: [UserRouteAccessService], + }, { path: ':courseId/programming-exercises/:exerciseId', component: ProgrammingExerciseDetailComponent, diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts index cefae879520a..ba7c48b92ea1 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts @@ -18,6 +18,7 @@ import { OrionModule } from 'app/shared/orion/orion.module'; import { ArtemisPlagiarismModule } from 'app/exercises/shared/plagiarism/plagiarism.module'; import { ArtemisProgrammingExerciseLifecycleModule } from 'app/exercises/programming/shared/lifecycle/programming-exercise-lifecycle.module'; import { ProgrammingExerciseInstructorExerciseDownloadComponent } from '../shared/actions/programming-exercise-instructor-exercise-download.component'; +import { ProgrammingExerciseInstructorExerciseSharingComponent } from '../shared/actions/programming-exercise-instructor-exercise-sharing.component'; import { SubmissionResultStatusModule } from 'app/overview/submission-result-status.module'; import { ProgrammingExerciseExampleSolutionRepoDownloadComponent } from 'app/exercises/programming/shared/actions/programming-exercise-example-solution-repo-download.component'; import { TestwiseCoverageReportModule } from 'app/exercises/programming/hestia/testwise-coverage-report/testwise-coverage-report.module'; @@ -62,6 +63,7 @@ import { CodeEditorHeaderComponent } from 'app/exercises/programming/shared/code ProgrammingExerciseDetailComponent, ProgrammingExerciseEditSelectedComponent, ProgrammingExerciseInstructorExerciseDownloadComponent, + ProgrammingExerciseInstructorExerciseSharingComponent, ProgrammingExerciseExampleSolutionRepoDownloadComponent, BuildPlanEditorComponent, ], diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise.component.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise.component.ts index 3a33c7b88f83..b46b21470472 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise.component.ts @@ -36,10 +36,12 @@ import { import { CourseExerciseService } from 'app/exercises/shared/course-exercises/course-exercise.service'; import { downloadZipFileFromResponse } from 'app/shared/util/download.util'; import { PROFILE_LOCALCI, PROFILE_LOCALVC, PROFILE_THEIA } from 'app/app.constants'; +import { SharingInfo } from 'app/sharing/sharing.model'; @Component({ selector: 'jhi-programming-exercise', templateUrl: './programming-exercise.component.html', + providers: [SharingInfo], }) export class ProgrammingExerciseComponent extends ExerciseComponent implements OnInit, OnDestroy { protected exerciseService = inject(ExerciseService); diff --git a/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-sharing.service.ts b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-sharing.service.ts new file mode 100644 index 000000000000..123a54e42aa6 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/services/programming-exercise-sharing.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { map } from 'rxjs/operators'; +import dayjs from 'dayjs/esm'; + +import { TemplateProgrammingExerciseParticipation } from 'app/entities/participation/template-programming-exercise-participation.model'; +import { SolutionProgrammingExerciseParticipation } from 'app/entities/participation/solution-programming-exercise-participation.model'; + +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; +import { SharingInfo, ShoppingBasket } from 'app/sharing/sharing.model'; +import { Course } from 'app/entities/course.model'; + +export type EntityResponseType = HttpResponse; +export type EntityArrayResponseType = HttpResponse; + +/** the programming exercise sharing service */ +@Injectable({ providedIn: 'root' }) +export class ProgrammingExerciseSharingService { + baseSharingConfigUrl = 'api/sharing/config'; + resourceUrl = 'api/sharing/import'; + resourceUrlBasket = 'api/sharing/import/basket/'; + resourceUrlExport = 'api/sharing/export'; + + constructor(private http: HttpClient) {} + + /** + * loads the Shopping Basket via the Service + */ + getSharedExercises(sharingInfo: SharingInfo): Observable { + return this.http + .get(this.resourceUrl + '/basket', { + params: { basketToken: sharingInfo.basketToken, apiBaseUrl: sharingInfo.apiBaseURL }, + observe: 'response', + }) + .pipe(map((response: HttpResponse) => response.body!)); + } + + /** + * loads the problem statment via the server + */ + loadProblemStatementForExercises(sharingInfo: SharingInfo): Observable { + const headers = new HttpHeaders(); + return this.http.post(this.resourceUrlBasket + 'problemStatement', sharingInfo, { headers, responseType: 'text' as 'json' }); + } + + loadDetailsForExercises(sharingInfo: SharingInfo): Observable { + return this.http.post(this.resourceUrlBasket + 'exerciseDetails', sharingInfo); + } + + /** + * Sets a new programming exercise up + * @param programmingExercise which should be setup + * @param course in which the exercise should be imported + * @param sharingInfo sharing related data needed for the import + */ + setUpFromSharingImport(programmingExercise: ProgrammingExercise, course: Course, sharingInfo: SharingInfo): Observable { + let copy = this.convertDataFromClient(programmingExercise); + copy = ExerciseService.setBonusPointsConstrainedByIncludedInOverallScore(copy); + return this.http + .post('api/sharing/setup-import', { exercise: copy, course, sharingInfo }, { observe: 'response' }) + .pipe(map((res: EntityResponseType) => this.convertDateFromServer(res))); + } + + /** + * Converts the data from the client + * if template & solution participation exist removes the exercise and results from them + * @param exercise for which the data should be converted + */ + convertDataFromClient(exercise: ProgrammingExercise) { + const copy = { + ...ExerciseService.convertExerciseDatesFromClient(exercise), + buildAndTestStudentSubmissionsAfterDueDate: + exercise.buildAndTestStudentSubmissionsAfterDueDate && dayjs(exercise.buildAndTestStudentSubmissionsAfterDueDate).isValid() + ? dayjs(exercise.buildAndTestStudentSubmissionsAfterDueDate).toJSON() + : undefined, + }; + // Remove exercise from template & solution participation to avoid circular dependency issues. + // Also remove the results, as they can have circular structures as well and don't have to be saved here. + if (copy.templateParticipation) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { exercise, results, ...filteredTemplateParticipation } = copy.templateParticipation; + copy.templateParticipation = { ...filteredTemplateParticipation } as TemplateProgrammingExerciseParticipation; + } + if (copy.solutionParticipation) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { exercise, results, ...filteredSolutionParticipation } = copy.solutionParticipation; + copy.solutionParticipation = { ...filteredSolutionParticipation } as SolutionProgrammingExerciseParticipation; + } + copy.categories = ExerciseService.stringifyExerciseCategories(copy); + + return copy as ProgrammingExercise; + } + + /** + * Convert all date fields of the programming exercise to momentJs date objects. + * Note: This conversion could produce an invalid date if the date is malformatted. + * + * @param entity ProgrammingExercise + */ + convertDateFromServer(entity: EntityResponseType) { + const res = ExerciseService.convertExerciseResponseDatesFromServer(entity); + if (!res.body) { + return res; + } + res.body.buildAndTestStudentSubmissionsAfterDueDate = res.body.buildAndTestStudentSubmissionsAfterDueDate + ? dayjs(res.body.buildAndTestStudentSubmissionsAfterDueDate) + : undefined; + return res; + } + + /** + * Used to initiate export to Sharing. + * This returns a URL to Sharing with a callback as parameter to the exposed exercise in Artemis + * @param programmingExerciseId id of the exercise to export + * @param callBackUrl used to redirect back after export has been completed + */ + exportProgrammingExerciseToSharing(programmingExerciseId: number, callBackUrl: string): Observable> { + return this.http.post(this.resourceUrlExport + `/${programmingExerciseId}`, callBackUrl, { + observe: 'response', + }); + } + + /** + * Check if the Sharing platform integration has been enabled. + * If enabled the request will return a 200, and a 503 if not. + */ + isSharingEnabled() { + return this.http.get(this.baseSharingConfigUrl + '/is-enabled', { + observe: 'response', + }); + } +} diff --git a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-creation-config.ts b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-creation-config.ts index 36c9b8df3a8c..bb787c530533 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-creation-config.ts +++ b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-creation-config.ts @@ -22,6 +22,7 @@ export type ProgrammingExerciseCreationConfig = { invalidRepositoryNamePattern: RegExp; isImportFromExistingExercise: boolean; isImportFromFile: boolean; + isImportFromSharing: boolean; appNamePatternForSwift: string; modePickerOptions?: ModePickerOption[]; withDependencies: boolean; diff --git a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.html b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.html index 4092b38225b2..6ce3a7870482 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.html +++ b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.component.html @@ -1,10 +1,10 @@
- @if (!isImportFromExistingExercise && !isImportFromFile && !programmingExercise.id) { + @if (!isImportFromExistingExercise && !(isImportFromFile || isImportFromSharing) && !programmingExercise.id) {

} @else if (!isImportFromExistingExercise && programmingExercise.id) {

- } @else if (isImportFromExistingExercise || isImportFromFile) { + } @else if (isImportFromExistingExercise || isImportFromFile || isImportFromSharing) {

} @@ -16,7 +16,7 @@

{ this.isImportFromExistingExercise = segments.some((segment) => segment.path === 'import'); this.isImportFromFile = segments.some((segment) => segment.path === 'import-from-file'); + this.isImportFromSharing = segments.some((segment) => segment.path === 'import-from-sharing'); this.isEdit = segments.some((segment) => segment.path === 'edit'); this.isCreate = segments.some((segment) => segment.path === 'new'); }), @@ -447,6 +456,9 @@ export class ProgrammingExerciseUpdateComponent implements AfterViewInit, OnDest if (this.isImportFromFile) { this.createProgrammingExerciseForImportFromFile(); } + if (this.isImportFromSharing) { + this.createProgrammingExerciseForImportFromSharing(); + } if (this.isImportFromExistingExercise) { this.createProgrammingExerciseForImport(params); } else { @@ -459,7 +471,7 @@ export class ProgrammingExerciseUpdateComponent implements AfterViewInit, OnDest } }); // we need the course id to make the request to the server if it's an import from file - if (this.isImportFromFile) { + if (this.isImportFromFile || this.isImportFromSharing) { this.courseId = params['courseId']; this.loadCourseExerciseCategories(params['courseId']); } @@ -484,7 +496,7 @@ export class ProgrammingExerciseUpdateComponent implements AfterViewInit, OnDest // If an exercise is created, load our readme template so the problemStatement is not empty this.selectedProgrammingLanguage = this.programmingExercise.programmingLanguage!; - if (this.programmingExercise.id || this.isImportFromFile) { + if (this.programmingExercise.id || this.isImportFromFile || this.isImportFromSharing) { this.problemStatementLoaded = true; } // Select the correct pattern @@ -616,6 +628,7 @@ export class ProgrammingExerciseUpdateComponent implements AfterViewInit, OnDest */ private createProgrammingExerciseForImport(params: Params) { this.isImportFromExistingExercise = true; + this.isImportFromSharing = false; this.originalStaticCodeAnalysisEnabled = this.programmingExercise.staticCodeAnalysisEnabled; // The source exercise is injected via the Resolver. The route parameters determine the target exerciseGroup or course const courseId = params['courseId']; @@ -692,7 +705,7 @@ export class ProgrammingExerciseUpdateComponent implements AfterViewInit, OnDest this.programmingExercise.buildConfig!.windfile.metadata.docker.image = this.programmingExercise.buildConfig!.windfile.metadata.docker.image.trim(); } - if (this.programmingExercise.customizeBuildPlanWithAeolus || this.isImportFromFile) { + if (this.programmingExercise.customizeBuildPlanWithAeolus || this.isImportFromFile || this.isImportFromSharing) { this.programmingExercise.buildConfig!.buildPlanConfiguration = this.aeolusService.serializeWindFile(this.programmingExercise.buildConfig!.windfile!); } else { this.programmingExercise.buildConfig!.buildPlanConfiguration = undefined; @@ -741,6 +754,16 @@ export class ProgrammingExerciseUpdateComponent implements AfterViewInit, OnDest } if (this.isImportFromFile) { this.subscribeToSaveResponse(this.programmingExerciseService.importFromFile(this.programmingExercise, this.courseId)); + } else if (this.isImportFromSharing) { + this.courseService.find(this.courseId).subscribe((res) => { + this.programmingExerciseSharingService.setUpFromSharingImport(this.programmingExercise, res.body!, this.sharingInfo).subscribe({ + next: (response: HttpResponse) => { + this.alertService.success('artemisApp.programmingExercise.created', { param: this.programmingExercise.title }); + this.onSaveSuccess(response.body!); + }, + error: (err) => this.onSaveError(err), + }); + }); } else if (this.isImportFromExistingExercise) { this.subscribeToSaveResponse(this.programmingExerciseService.importExercise(this.programmingExercise, this.importOptions)); } else if (this.programmingExercise.id !== undefined) { @@ -897,16 +920,30 @@ export class ProgrammingExerciseUpdateComponent implements AfterViewInit, OnDest this.hasUnsavedChanges = false; this.problemStatementLoaded = false; this.programmingExercise.programmingLanguage = language; - this.fileService.getTemplateFile(this.programmingExercise.programmingLanguage, this.programmingExercise.projectType).subscribe({ - next: (file) => { - this.programmingExercise.problemStatement = file; - this.problemStatementLoaded = true; - }, - error: () => { - this.programmingExercise.problemStatement = ''; - this.problemStatementLoaded = true; - }, - }); + if (!this.isImportFromSharing) { + this.fileService.getTemplateFile(this.programmingExercise.programmingLanguage, this.programmingExercise.projectType).subscribe({ + next: (file) => { + this.programmingExercise.problemStatement = file; + this.problemStatementLoaded = true; + }, + error: () => { + this.programmingExercise.problemStatement = ''; + this.problemStatementLoaded = true; + }, + }); + } else { + this.programmingExerciseSharingService.loadProblemStatementForExercises(this.sharingInfo).subscribe({ + next: (statement: string) => { + this.programmingExercise.problemStatement = statement; + this.problemStatementLoaded = true; + }, + error: () => { + this.alertService.error('Failed to load problem statement from the sharing platform.'); + this.programmingExercise.problemStatement = ''; + this.problemStatementLoaded = true; + }, + }); + } } /** @@ -1222,9 +1259,37 @@ export class ProgrammingExerciseUpdateComponent implements AfterViewInit, OnDest this.selectedProjectType = history.state.programmingExerciseForImportFromFile.projectType; } + private createProgrammingExerciseForImportFromSharing() { + this.activatedRoute.queryParams.subscribe((qparams: Params) => { + this.sharingInfo.basketToken = qparams['basketToken']; + this.sharingInfo.returnURL = qparams['returnUrl']; + this.sharingInfo.apiBaseURL = qparams['apiBaseUrl']; + this.sharingInfo.selectedExercise = qparams['selectedExercise']; + this.programmingExerciseSharingService.loadDetailsForExercises(this.sharingInfo).subscribe( + (exerciseDetails: ProgrammingExercise) => { + if (!exerciseDetails.buildConfig) { + exerciseDetails.buildConfig = new ProgrammingExerciseBuildConfig(); + } + history.state.programmingExerciseForImportFromFile = exerciseDetails; + + this.createProgrammingExerciseForImportFromFile(); + }, + (error) => { + this.alertService.error('Failed to load exercise details from the sharing platform: ' + error); + }, + ); + }); + /* + this.programmingExerciseSharingService.loadProblemStatementForExercises(this.sharingInfo).subscribe((statement: string) => { + this.programmingExercise.problemStatement = statement; + }); + */ + } + getProgrammingExerciseCreationConfig(): ProgrammingExerciseCreationConfig { return { isImportFromFile: this.isImportFromFile, + isImportFromSharing: this.isImportFromSharing, isImportFromExistingExercise: this.isImportFromExistingExercise, showSummary: false, isEdit: this.isEdit, diff --git a/src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-instructor-exercise-sharing.component.ts b/src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-instructor-exercise-sharing.component.ts new file mode 100644 index 000000000000..cb75a6b5e01f --- /dev/null +++ b/src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-instructor-exercise-sharing.component.ts @@ -0,0 +1,77 @@ +import { Component, Input } from '@angular/core'; +import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; +import { FeatureToggle } from 'app/shared/feature-toggle/feature-toggle.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { faDownload } from '@fortawesome/free-solid-svg-icons'; +import { ProgrammingExerciseSharingService } from '../../manage/services/programming-exercise-sharing.service'; +import { HttpResponse } from '@angular/common/http'; + +@Component({ + selector: 'jhi-programming-exercise-instructor-exercise-sharing', + template: ` + + `, +}) +export class ProgrammingExerciseInstructorExerciseSharingComponent { + ButtonType = ButtonType; + ButtonSize = ButtonSize; + readonly FeatureToggle = FeatureToggle; + sharingTab: WindowProxy | null; + + @Input() + exerciseId: number; + + // Icons + faDownload = faDownload; + + constructor( + private sharingService: ProgrammingExerciseSharingService, + private alertService: AlertService, + ) {} + + preOpenSharingTab() { + // the focus back to this window is not working, so we open in this window + /* + if(!this.sharingTab) { + this.sharingTab = window.open("about:blank", "sharing"); + } + */ + } + /** + * **CodeAbility changes**: Used to initiate export of an exercise to + * Sharing. + * Results in a redirect containing a callback-link to exposed exercise + * @param programmingExerciseId the id of the exercise to export + */ + exportExerciseToSharing(programmingExerciseId: number) { + this.sharingService.exportProgrammingExerciseToSharing(programmingExerciseId, window.location.href).subscribe({ + next: (redirect: HttpResponse) => { + if (redirect) { + const redirectURL = redirect.body?.toString(); + if (this.sharingTab) { + if (!window.name) { + window.name = 'artemis'; + } + this.sharingTab.location.href = redirectURL! + '&window=' + window.name; + this.sharingTab.focus(); + // const sharingWindow = window.open(redirectURL, 'sharing'); + } else { + window.location.href = redirectURL!; + } + } + }, + error: (errorResponse) => { + this.alertService.error('Unable to export exercise. Error: ' + errorResponse.message); + }, + }); + } +} diff --git a/src/main/webapp/app/sharing/search-result-dto.model.ts b/src/main/webapp/app/sharing/search-result-dto.model.ts new file mode 100644 index 000000000000..80af81e181a2 --- /dev/null +++ b/src/main/webapp/app/sharing/search-result-dto.model.ts @@ -0,0 +1,72 @@ +export interface SearchResultDTO { + project: ProjectDTO; + file: MetadataFileDTO; + metadata: UserProvidedMetadataDTO; + ranking5: number; + supportedActions: PluginActionInfo[]; + views: number; + downloads: number; +} + +export interface PluginActionInfo { + plugin: string; + action: string; + commandName: string; +} + +export interface UserProvidedMetadataDTO { + contributor: Array; + creator: Array; + deprecated: boolean; + description: string; + difficulty: string; + educationLevel: string; + format: Array; + identifier: string; + image: string; + keyword: Array; + language: Array; + license: string; + metadataVersion: string; + programmingLanguage: Array; + collectionContent: Array; + publisher: Array; + requires: Array; + source: Array; + status: string; + structure: string; + timeRequired: string; + title: string; + type: IExerciseType; + version: string; +} + +export interface Person { + name: string; + email: string; + affiliation: string; +} + +export enum IExerciseType { + COLLECTION = 'collection', + PROGRAMMING_EXERCISE = 'programming exercise', + EXERCISE = 'exercise', + OTHER = 'other', +} + +export interface ProjectDTO { + project_id: string; + project_name: string; + namespace: string; + main_group: string; + sub_group: string; + url: string; + last_activity_at: Date; +} + +export interface MetadataFileDTO { + filename: string; + path: string; + commit_id: string; + indexing_date: Date; +} diff --git a/src/main/webapp/app/sharing/sharing.component.html b/src/main/webapp/app/sharing/sharing.component.html new file mode 100644 index 000000000000..0e0a54822f2e --- /dev/null +++ b/src/main/webapp/app/sharing/sharing.component.html @@ -0,0 +1,103 @@ + +
+ + + + + + + + + + + + + +
Imported ExerciseCourse to import
+
+
+
Keine importierten Aufgaben gefunden
+ + + + + + + + + + + + + +
Exercise to import
+ + {{ exercise.metadata.title }}
+
+
+ +
expires at:
+
+ Please ensure to log in as instructor! +
+
+
+
+ + + + + + + + + + + + + + + + + +
+ + +
 
+
+ {{ course.id }} + + {{ course.title }}
+ : {{ course.shortName }} +
+
+
+
+ +
+
+ diff --git a/src/main/webapp/app/sharing/sharing.component.ts b/src/main/webapp/app/sharing/sharing.component.ts new file mode 100644 index 000000000000..63c1b96fb408 --- /dev/null +++ b/src/main/webapp/app/sharing/sharing.component.ts @@ -0,0 +1,120 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Params, Router } from '@angular/router'; +import { AccountService } from 'app/core/auth/account.service'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { Course } from 'app/entities/course.model'; +import { SortService } from 'app/shared/service/sort.service'; +import { ARTEMIS_DEFAULT_COLOR } from 'app/app.constants'; +import { AuthServerProvider } from 'app/core/auth/auth-jwt.service'; +import { SharingInfo, ShoppingBasket } from './sharing.model'; +import { ProgrammingExerciseSharingService } from 'app/exercises/programming/manage/services/programming-exercise-sharing.service'; +import { LoginService } from 'app/core/login/login.service'; +import { StateStorageService } from 'app/core/auth/state-storage.service'; +import { UserRouteAccessService } from 'app/core/auth/user-route-access-service'; +import { Authority } from 'app/shared/constants/authority.constants'; +import { faPlus, faSort } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'jhi-sharing', + templateUrl: './sharing.component.html', + styleUrls: ['./sharing.scss'], +}) +export class SharingComponent implements OnInit { + courses: Course[]; + + readonly ARTEMIS_DEFAULT_COLOR = ARTEMIS_DEFAULT_COLOR; + reverse: boolean; + predicate: string; + shoppingBasket: ShoppingBasket; + sharingInfo: SharingInfo = new SharingInfo(); + selectedCourse: Course; + isInstructor = false; + + // Icons + faPlus = faPlus; + faSort = faSort; + + constructor( + private route: ActivatedRoute, + private router: Router, + private authServerProvider: AuthServerProvider, + private accountService: AccountService, + private userRouteAccessService: UserRouteAccessService, + private loginService: LoginService, + private stateStorageService: StateStorageService, + private courseService: CourseManagementService, + private sortService: SortService, + private programmingExerciseSharingService: ProgrammingExerciseSharingService, + ) { + this.route.params.subscribe((params) => { + this.sharingInfo.basketToken = params['basketToken']; + }); + this.route.queryParams.subscribe((qparams: Params) => { + this.sharingInfo.returnURL = qparams['returnURL']; + this.sharingInfo.apiBaseURL = qparams['apiBaseURL']; + this.programmingExerciseSharingService.getSharedExercises(this.sharingInfo).subscribe((res: ShoppingBasket) => { + this.shoppingBasket = res; + }); + }); + this.predicate = 'id'; + } + + getTokenExpiryDate(): Date { + if (this.shoppingBasket) { + return new Date(this.shoppingBasket.tokenValidUntil); + } + return new Date(); + } + /** + * loads all courses from courseService + */ + loadAll() { + this.courseService.getWithUserStats(false).subscribe({ + next: (res: HttpResponse) => { + this.courses = res.body!; + }, + error: (res: HttpErrorResponse) => alert('Cannot load courses: [' + res.message + ']'), + }); + } + + onCourseSelected(course: Course): void { + this.selectedCourse = course; + } + + courseId(): number { + if (this.selectedCourse && this.selectedCourse.id) { + return this.selectedCourse.id; + } + return 0; + } + + onExerciseSelected(index: number): void { + this.sharingInfo.selectedExercise = index; + } + + /** + * Returns the unique identifier for items in the collection + * @param index - Index of a course in the collection + * @param item - Current course + */ + trackId(index: number, item: Course) { + return item.id; + } + + sortRows() { + this.sortService.sortByProperty(this.courses, this.predicate, this.reverse); + } + + /** + * Initialises the sharing page for import + */ + ngOnInit(): void { + this.userRouteAccessService.checkLogin([Authority.EDITOR, Authority.INSTRUCTOR, Authority.ADMIN], this.router.url).then((isLoggedIn) => { + if (isLoggedIn) { + this.isInstructor = true; + this.loadAll(); + } + }); + } +} diff --git a/src/main/webapp/app/sharing/sharing.model.ts b/src/main/webapp/app/sharing/sharing.model.ts new file mode 100644 index 000000000000..74332b850a40 --- /dev/null +++ b/src/main/webapp/app/sharing/sharing.model.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@angular/core'; +import { SearchResultDTO } from './search-result-dto.model'; +@Injectable() +export class SharingInfo { + /** Token representing the current shopping basket */ + public basketToken = ''; + /** URL to return to after completing sharing operation */ + public returnURL: ''; + /** Base URL for the sharing platform API */ + public apiBaseURL: ''; + /** ID of the currently selected exercise */ + public selectedExercise = 0; + + /** + * Checks if a shopping basket is currently available + * @returns true if a basket token exists + */ + public isAvailable(): boolean { + return this.basketToken !== ''; + } + /** + * Clears all sharing-related state + */ + public clear(): void { + this.basketToken = ''; + this.selectedExercise = 0; + this.returnURL = ''; + this.apiBaseURL = ''; + } + + /** + * Validates that all required sharing information is present + * @throws Error if any required information is missing + */ + public validate(): void { + if (!this.basketToken) { + throw new Error('Basket token is required'); + } + if (!this.apiBaseURL) { + throw new Error('API base URL is required'); + } + } +} + +/** + * Represents a shopping basket containing exercises to be shared + */ +export interface ShoppingBasket { + exerciseInfo: Array; + userInfo: UserInfo; + tokenValidUntil: Date; +} +/** + * Represents user information for sharing operations + */ +export interface UserInfo { + /** User's email address for sharing notifications */ + email: string; +} + +/** + * Validates an email address + * @param email The email address to validate + * @throws Error if the email is invalid + */ +export function validateEmail(email: string): void { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + throw new Error('Invalid email address'); + } +} diff --git a/src/main/webapp/app/sharing/sharing.module.ts b/src/main/webapp/app/sharing/sharing.module.ts new file mode 100644 index 000000000000..6088ed11aa58 --- /dev/null +++ b/src/main/webapp/app/sharing/sharing.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { SharingComponent } from 'app/sharing/sharing.component'; +import { featureOverviewState } from 'app/sharing/sharing.route'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; + +const SHARING_ROUTES = [...featureOverviewState]; +@NgModule({ + imports: [RouterModule.forChild(SHARING_ROUTES), ArtemisSharedModule], + declarations: [SharingComponent], +}) +export class SharingModule {} diff --git a/src/main/webapp/app/sharing/sharing.route.ts b/src/main/webapp/app/sharing/sharing.route.ts new file mode 100644 index 000000000000..dd55044c0cce --- /dev/null +++ b/src/main/webapp/app/sharing/sharing.route.ts @@ -0,0 +1,23 @@ +import { Routes } from '@angular/router'; +import { SharingComponent } from 'app/sharing/sharing.component'; +import { Authority } from 'app/shared/constants/authority.constants'; + +export const sharingRoutes: Routes = [ + { + path: '', + component: SharingComponent, + data: { + pageTitle: 'artemisApp.sharing.title', + authorities: [Authority.EDITOR, Authority.INSTRUCTOR, Authority.ADMIN], + }, + }, +]; + +const SHARING_ROUTES = [...sharingRoutes]; + +export const featureOverviewState: Routes = [ + { + path: '', + children: SHARING_ROUTES, + }, +]; diff --git a/src/main/webapp/app/sharing/sharing.scss b/src/main/webapp/app/sharing/sharing.scss new file mode 100644 index 000000000000..24d3334eaae3 --- /dev/null +++ b/src/main/webapp/app/sharing/sharing.scss @@ -0,0 +1,262 @@ +body { + margin: 0; + padding: 0; + font-family: 'Ubuntu', sans-serif; + display: flex; +} + +.header { + position: relative; + text-align: center; + background: #3070b3; +} + +.curve { + fill: #fff; + height: 100px; + width: 100%; + position: absolute; + bottom: 0; + left: 0; +} + +.header h1 { + color: #fff; + margin: 0; + padding: 1em 2em; + font-size: 5em; + text-shadow: 1px 1px 40px #333; + box-sizing: border-box; +} + +.content { + padding: 20px; +} + +.wrapper { + float: left; + align-content: center; + align-items: center; + box-sizing: border-box; + width: 100%; + background: #fff; +} + +.features-overview { + margin: 0; + padding-left: 20%; + padding-right: 20%; + text-align: center; + color: #3070b3; +} + +.content h2 { + margin: 0; + padding-left: 20%; + padding-right: 20%; + text-align: center; + color: #3070b3; + font-size: 3em; + padding-bottom: 1em; + box-sizing: border-box; +} + +.container { + width: 1200px; + height: auto; + margin: 0 auto; + display: grid; + align-items: center; + align-self: center; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + grid-gap: 15px; + padding: 10px; + box-sizing: border-box; + cursor: pointer; +} + +.feature-content { + margin: 0; + box-sizing: border-box; + padding-left: 35px; + padding-right: 35px; +} + +.feature-content h3 { + margin: 0; + text-align: center; + color: #3070b3; + font-size: 2em; + padding: 1em 20% 0.5em; + box-sizing: border-box; +} + +.feature-full-description { + overflow: auto; + padding: 35px; +} + +.feature-full-description p { + width: 100%; + font-size: 18px; + line-height: 26px; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; + hyphens: auto; +} + +.container .box { + position: relative; + height: 250px; + background: #3070b3; + padding: 15px 15px 15px; + text-align: left; + box-sizing: border-box; + overflow: hidden; + border-radius: 10px; +} + +.container .box .icon { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #f2f2f2; + transition: 0.5s; + z-index: 1; +} + +.container .box:hover .icon { + top: 20px; + left: calc(50% - 40px); + width: 80px; + height: 80px; + border-radius: 50%; + cursor: pointer; +} + +.container .box .icon .title { + position: absolute; + margin-left: 20px; + display: inline-block; + top: calc(50% + 10px); + padding: 20px 10px; + color: #3070b3; + font-size: 22px; + width: calc(100% - 40px); + text-align: center; + opacity: 1; +} + +.container .box .icon .fa { + position: absolute; + top: calc(50% - 20px); + left: 50%; + transform: translate(-50%, -50%); + font-size: 80px; + transition: 0.5s; + color: #3070b3; +} + +.container .box:hover .icon .fa { + top: 50%; + font-size: 40px; +} + +.container .box:hover .icon .title { + opacity: 0; +} + +.container .box .feature-short-description { + position: absolute; + top: 100%; + height: calc(100% - 120px); + width: calc(100% - 40px); + box-sizing: border-box; + font-size: 18px; + transition: 0.5s; + opacity: 0; +} + +.container .box:hover .feature-short-description { + top: 100px; + opacity: 1; + text-align: center; +} + +.container .box .feature-short-description h3 { + margin: 0; + padding: 0; + color: #fff; + font-size: 24px; + text-align: center; +} + +.container .box .feature-short-description p { + margin: 0; + padding: 0; + color: #fff; + font-size: 20px; +} + +hr.hr-gradient { + height: 10px; + position: relative; + width: calc(100% - 300px); + background: radial-gradient(ellipse farthest-side at top center, rgba(0, 0, 0, 0.06), transparent); +} + +hr.hr-gradient::before { + content: ''; + display: block; + position: absolute; + top: 0; + right: 0; + left: 0; + height: 3px; + background: linear-gradient(to left, transparent, rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.02), transparent); +} + +hr.hr-text { + position: relative; + border: none; + height: 1px; + background-color: #3070b3; +} + +hr.hr-text::before { + content: attr(data-content); + display: inline-block; + background-color: #fff; + font-weight: bold; + font-size: 0.85rem; + color: #fff; + border-radius: 30rem; + padding: 0.2rem 2rem; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.text-wrap-right { + float: right; + margin-left: 15px; + margin-bottom: 7px; +} + +.text-wrap-left { + clear: right; + float: left; + margin-right: 15px; + margin-bottom: 7px; +} + +.center { + clear: right; + display: block; + margin: 30px auto; + width: 50%; +} diff --git a/src/main/webapp/i18n/de/programmingExercise.json b/src/main/webapp/i18n/de/programmingExercise.json index 850fc2330e0d..a9564e5cdac5 100644 --- a/src/main/webapp/i18n/de/programmingExercise.json +++ b/src/main/webapp/i18n/de/programmingExercise.json @@ -646,7 +646,7 @@ "tooltip": "Das System zieht für jede Abgabe, die das Abgabelimit überschreitet, Punkte vom Gesamtergebnis ab. Der Punktabzug für diese Aufgabe ist -{{points}} Punkte." } }, - "submissionLimitTitle": "Abgaben", + "submissionLimitTitle": "Abgabelimit", "submissionLimitDescription": "Die Anzahl von erlaubten Abgaben, bis das System die ausgewählte Abgaberichtlinie durchsetzt.", "editInGradingInformation": "Das Abgabelimit kann nur in der Bewertungssicht der Programmieraufgabe bearbeitet und (de)aktiviert werden.", "goToGradingToEditInformation": "Gehe zur Bewertungsseite, um die Abgaberichtlinie zu bearbeiten.", @@ -769,6 +769,10 @@ } } }, + "sharing": { + "import": "Programmieraufgabe aus Sharing importieren", + "export": "Exportieren nach Sharing" + } }, "error": { "noparticipations": "Nutzer:in existiert nicht oder es gibt keine Abgaben.", "shortNameGenerationFailed": "Der Kurzname konnte nicht generiert werden, bitte wechsle zum erweiterten Modus und setze den Kurznamen manuell." diff --git a/src/main/webapp/i18n/de/sharing.json b/src/main/webapp/i18n/de/sharing.json new file mode 100644 index 000000000000..125b75d2d11f --- /dev/null +++ b/src/main/webapp/i18n/de/sharing.json @@ -0,0 +1,13 @@ +{ + "artemisApp": { + "sharing": { + "title": "Import aus Austauschplattform", + "expiresAt": "Verbindung läuft ab um {{date}}", + "importedExercise": "Importierte Aufgaben", + "courseToImport": "Kurs in den die Aufgabe importiert werden soll", + "noExercisesFound": "Keine importierten Aufgaben gefunden?", + "exerciseToImport": "Bitte die zu importierende Aufgabe auswählen", + "loginAsInstructor": "Bitte melden Sie sich als Instruktor ein!" + } + } +} diff --git a/src/main/webapp/i18n/en/programmingExercise.json b/src/main/webapp/i18n/en/programmingExercise.json index 182a28afc520..6c68c3bb7396 100644 --- a/src/main/webapp/i18n/en/programmingExercise.json +++ b/src/main/webapp/i18n/en/programmingExercise.json @@ -754,18 +754,9 @@ "pushSolution": "Push the solution code to its repository. It will not be checked out.", "pushTest": "Push the test code to its repository. It will be checked out in {{checkoutDirectory}}." }, - "dockerFlags": { - "disableNetworkAccess": { - "title": "Disable internet access when executing the student submission", - "description": "Activate this option to disable network access for containers. The network will be disabled when executing the student submission.", - "warning": "If internet access is disabled, all dependencies must be included in the Docker image or cached within it. Otherwise, the build will fail." - }, - "envVars": { - "title": "Environment Variables", - "description": "Add environment variables to the Docker container. Each key and value should not be longer than 1000 characters.", - "addEnvVar": "Add Environment Variable", - "removeEnvVar": "Remove" - } + "sharing": { + "import": "Import Programming Exercise from Sharing Platform", + "export": "Export to Sharing" } }, "error": { diff --git a/src/main/webapp/i18n/en/sharing.json b/src/main/webapp/i18n/en/sharing.json new file mode 100644 index 000000000000..44ac575fcd81 --- /dev/null +++ b/src/main/webapp/i18n/en/sharing.json @@ -0,0 +1,13 @@ +{ + "artemisApp": { + "sharing": { + "title": "Import from Sharing Plattform", + "expiresAt": "Connection expires at {{date}}", + "importedExercise": "Imported Exercise", + "courseToImport": "Course to import Exercise into", + "noExercisesFound": "No imported exercises found?", + "exerciseToImport": "Please select exercise to import", + "loginAsInstructor": "Please ensure to log in as instructor!" + } + } +}