diff --git a/.gitignore b/.gitignore index 0b33649..3e33783 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ build/ .DS_Store /.autograder-tmp +/test_content diff --git a/pom.xml b/pom.xml index 429e5ae..c99480a 100644 --- a/pom.xml +++ b/pom.xml @@ -57,6 +57,7 @@ 2.0.16 4.12.0 0.6.0 + 6.10.0.202406032230-r 5.11.0-M2 2.17.1 @@ -99,10 +100,17 @@ autograder-api ${autograder.version} + + org.eclipse.jgit org.eclipse.jgit - 6.10.0.202406032230-r + ${jgit.version} + + + org.eclipse.jgit + org.eclipse.jgit.ssh.apache + ${jgit.version} diff --git a/src/main/java/edu/kit/kastel/sdq/artemis4j/client/ArtemisInstance.java b/src/main/java/edu/kit/kastel/sdq/artemis4j/client/ArtemisInstance.java index 1d0c9c7..5ec49c1 100644 --- a/src/main/java/edu/kit/kastel/sdq/artemis4j/client/ArtemisInstance.java +++ b/src/main/java/edu/kit/kastel/sdq/artemis4j/client/ArtemisInstance.java @@ -41,9 +41,18 @@ public String getAPIBaseURL() { return this.protocol + this.domain + "/api"; } + public String getManagementBaseURL() { + return this.protocol + this.domain + "/management"; + } + public HttpUrl url(List pathComponents, Map queryParams) { + return this.url(pathComponents, queryParams, false); + } + + public HttpUrl url(List pathComponents, Map queryParams, boolean managementRequest) { + String baseUrl = managementRequest ? this.getManagementBaseURL() : this.getAPIBaseURL(); String path = pathComponents.stream().map(Object::toString).collect(Collectors.joining("/")); - var url = HttpUrl.parse(this.getAPIBaseURL() + "/" + path); + var url = HttpUrl.parse(baseUrl + "/" + path); if (queryParams != null && !queryParams.isEmpty()) { var builder = url.newBuilder(); queryParams.forEach((p, v) -> builder.addQueryParameter(p, v.toString())); diff --git a/src/main/java/edu/kit/kastel/sdq/artemis4j/client/ArtemisRequest.java b/src/main/java/edu/kit/kastel/sdq/artemis4j/client/ArtemisRequest.java index 8b917a7..0e7118b 100644 --- a/src/main/java/edu/kit/kastel/sdq/artemis4j/client/ArtemisRequest.java +++ b/src/main/java/edu/kit/kastel/sdq/artemis4j/client/ArtemisRequest.java @@ -12,6 +12,7 @@ public class ArtemisRequest { private final String method; private List path; + private boolean managementRequest = false; private final Map requestParams = new HashMap<>(); private Object body; @@ -40,6 +41,18 @@ public ArtemisRequest path(List path) { return this; } + /** + * + * @param managementRequest whether the request shall be performed against + * Artemis' "normal" API (/api), or against the + * management API (/management) + * @return + */ + public ArtemisRequest managementRequest(boolean managementRequest) { + this.managementRequest = managementRequest; + return this; + } + public ArtemisRequest body(E entity) { if (this.method.equals("GET")) { throw new IllegalArgumentException("GET requests cannot have a body"); @@ -63,7 +76,7 @@ public R executeAndDecode(ArtemisClient client, Class resultClass) throws request.method(this.method, ArtemisClient.encodeJSON(this.body)); } - request.url(client.getInstance().url(this.path, this.requestParams)); + request.url(client.getInstance().url(this.path, this.requestParams, this.managementRequest)); return client.call(request.build(), resultClass); } diff --git a/src/main/java/edu/kit/kastel/sdq/artemis4j/client/ManagementInfoDTO.java b/src/main/java/edu/kit/kastel/sdq/artemis4j/client/ManagementInfoDTO.java new file mode 100644 index 0000000..4e5faf4 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/artemis4j/client/ManagementInfoDTO.java @@ -0,0 +1,18 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.artemis4j.client; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; +import edu.kit.kastel.sdq.artemis4j.ArtemisNetworkException; + +/** + * There are many more fields in the actual entity that are added as needed + * + * @param sshCloneURLTemplate Base URL for cloning repositories via SSH + */ +public record ManagementInfoDTO(@JsonProperty String sshCloneURLTemplate) { + public static ManagementInfoDTO fetch(ArtemisClient client) throws ArtemisNetworkException { + return ArtemisRequest.get().path(List.of("info")).managementRequest(true).executeAndDecode(client, ManagementInfoDTO.class); + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/artemis4j/client/UserDTO.java b/src/main/java/edu/kit/kastel/sdq/artemis4j/client/UserDTO.java index 5c8c65b..4dc08fc 100644 --- a/src/main/java/edu/kit/kastel/sdq/artemis4j/client/UserDTO.java +++ b/src/main/java/edu/kit/kastel/sdq/artemis4j/client/UserDTO.java @@ -8,7 +8,8 @@ public record UserDTO(@JsonProperty long id, @JsonProperty String login, @JsonProperty String firstName, @JsonProperty String lastName, @JsonProperty String email, @JsonProperty boolean activated, @JsonProperty String langKey, @JsonProperty String lastNotificationRead, - @JsonProperty String name, @JsonProperty String participantIdentifier, @JsonProperty List groups, @JsonProperty String vcsAccessToken) { + @JsonProperty String name, @JsonProperty String participantIdentifier, @JsonProperty List groups, @JsonProperty String vcsAccessToken, + @JsonProperty String sshPublicKey) { public UserDTO { if (groups == null) { // when a user is not in any group, artemis returns null diff --git a/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/ArtemisConnection.java b/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/ArtemisConnection.java index a31ea24..8581983 100644 --- a/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/ArtemisConnection.java +++ b/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/ArtemisConnection.java @@ -9,6 +9,7 @@ import edu.kit.kastel.sdq.artemis4j.client.ArtemisClient; import edu.kit.kastel.sdq.artemis4j.client.ArtemisInstance; import edu.kit.kastel.sdq.artemis4j.client.CourseDTO; +import edu.kit.kastel.sdq.artemis4j.client.ManagementInfoDTO; import edu.kit.kastel.sdq.artemis4j.client.UserDTO; /** @@ -17,6 +18,7 @@ */ public final class ArtemisConnection { private final ArtemisClient client; + private final LazyNetworkValue managementInfo; private final LazyNetworkValue assessor; private final LazyNetworkValue> courses; @@ -30,6 +32,7 @@ public static ArtemisConnection fromToken(ArtemisInstance instance, String token public ArtemisConnection(ArtemisClient client) { this.client = client; + this.managementInfo = new LazyNetworkValue<>(() -> ManagementInfoDTO.fetch(this.client)); this.assessor = new LazyNetworkValue<>(() -> new User(UserDTO.getAssessingUser(this.client))); this.courses = new LazyNetworkValue<>(() -> CourseDTO.fetchAll(this.client).stream().map(dto -> new Course(dto, this)).toList()); } @@ -38,6 +41,10 @@ public ArtemisClient getClient() { return client; } + public ManagementInfoDTO getManagementInfo() throws ArtemisNetworkException { + return managementInfo.get(); + } + public User getAssessor() throws ArtemisNetworkException { return assessor.get(); } diff --git a/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/ClonedProgrammingSubmission.java b/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/ClonedProgrammingSubmission.java index 54d5f5b..331686b 100644 --- a/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/ClonedProgrammingSubmission.java +++ b/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/ClonedProgrammingSubmission.java @@ -6,13 +6,40 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; +import java.util.Optional; + +import javax.swing.BoxLayout; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPasswordField; import edu.kit.kastel.sdq.artemis4j.ArtemisClientException; +import edu.kit.kastel.sdq.artemis4j.ArtemisNetworkException; +import org.eclipse.jgit.api.CloneCommand; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.errors.UnsupportedCredentialItem; +import org.eclipse.jgit.transport.CredentialItem; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.SshSessionFactory; +import org.eclipse.jgit.transport.SshTransport; +import org.eclipse.jgit.transport.URIish; import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; - +import org.eclipse.jgit.transport.sshd.SshdSessionFactoryBuilder; +import org.eclipse.jgit.util.FS; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Represents a submission and associated tests repository that has been cloned + * to a local folder. + *

+ * Most of the logic in this class is required to support cloning via SSH. + */ public class ClonedProgrammingSubmission implements AutoCloseable { + private static final Logger log = LoggerFactory.getLogger(ClonedProgrammingSubmission.class); private final ProgrammingSubmission submission; private final Path testsPath; private final Path submissionPath; @@ -20,12 +47,15 @@ public class ClonedProgrammingSubmission implements AutoCloseable { static ClonedProgrammingSubmission cloneSubmission(ProgrammingSubmission submission, Path target, String tokenOverride) throws ArtemisClientException { var connection = submission.getConnection(); + // Cache credentials between both clones + var credentialsProvider = buildCredentialsProvider(tokenOverride, connection); + // Clone the test repository - cloneRepositoryInto(submission.getExercise().getTestRepositoryUrl(), target, tokenOverride, connection); + cloneRepositoryInto(submission.getExercise().getTestRepositoryUrl(), target, credentialsProvider, connection); // Clone the student's submission into a subfolder Path submissionPath = target.resolve("assignment"); - cloneRepositoryInto(submission.getRepositoryUrl(), submissionPath, tokenOverride, connection); + cloneRepositoryInto(submission.getRepositoryUrl(), submissionPath, credentialsProvider, connection); // Check out the submitted commit try (var repo = Git.open(submissionPath.toFile())) { @@ -59,25 +89,59 @@ public Path getSubmissionSourcePath() { return submissionPath.resolve("src"); } - private static void cloneRepositoryInto(String repositoryURL, Path target, String tokenOverride, ArtemisConnection connection) - throws ArtemisClientException { + private static CredentialsProvider buildCredentialsProvider(String tokenOverride, ArtemisConnection connection) throws ArtemisNetworkException { var assessor = connection.getAssessor(); - String username = assessor.getLogin(); - String token; - if (tokenOverride != null) { - token = tokenOverride; - } else if (assessor.getGitToken().isPresent()) { - token = assessor.getGitToken().get(); - } else if (connection.getClient().getPassword().isPresent()) { - token = connection.getClient().getPassword().get(); + if (tokenOverride == null && assessor.getGitSSHKey().isPresent()) { + return new InteractiveCredentialsProvider(); } else { - token = ""; + String token; + if (tokenOverride != null) { + token = tokenOverride; + } else if (assessor.getGitToken().isPresent()) { + token = assessor.getGitToken().get(); + } else if (connection.getClient().getPassword().isPresent()) { + token = connection.getClient().getPassword().get(); + } else { + token = ""; + } + return new UsernamePasswordCredentialsProvider(assessor.getLogin(), token); } + } + + private static void cloneRepositoryInto(String repositoryURL, Path target, CredentialsProvider credentialsProvider, ArtemisConnection connection) + throws ArtemisClientException { + var assessor = connection.getAssessor(); + + CloneCommand cloneCommand = Git.cloneRepository().setDirectory(target.toAbsolutePath().toFile()).setRemote("origin").setURI(repositoryURL) + .setCloneAllBranches(true).setCloneSubmodules(false).setCredentialsProvider(credentialsProvider); try { - Git.cloneRepository().setDirectory(target.toAbsolutePath().toFile()).setRemote("origin").setURI(repositoryURL).setCloneAllBranches(true) - .setCloneSubmodules(false).setCredentialsProvider(new UsernamePasswordCredentialsProvider(username, token)).call().close(); + if (assessor.getGitSSHKey().isPresent()) { + String sshTemplate = connection.getManagementInfo().sshCloneURLTemplate(); + if (sshTemplate == null) { + throw new IllegalStateException("SSH key is set, but the Artemis instance does not support SSH cloning"); + } + + String sshUrl = createSSHUrl(repositoryURL, sshTemplate); + log.info("Cloning repository via SSH from {}", sshUrl); + + var sshdFactoryBuilder = new SshdSessionFactoryBuilder().setHomeDirectory(FS.DETECTED.userHome()) + .setSshDirectory(new File(FS.DETECTED.userHome(), "/.ssh")).setPreferredAuthentications("publickey"); + + try (var sshdFactory = sshdFactoryBuilder.build(null)) { + SshSessionFactory.setInstance(sshdFactory); + cloneCommand.setTransportConfigCallback((transport -> { + if (transport instanceof SshTransport sshTransport) { + sshTransport.setSshSessionFactory(sshdFactory); + } + })).setURI(sshUrl).call().close(); + } + } else { + log.info("Cloning repository via HTTPS from {}", repositoryURL); + cloneCommand.setURI(repositoryURL).call().close(); + } + } catch (GitAPIException e) { throw new ArtemisClientException("Failed to clone the submission repository", e); } @@ -97,4 +161,79 @@ private static void deleteDirectory(Path path) throws IOException { dirStream.map(Path::toFile).sorted(Comparator.reverseOrder()).forEach(File::delete); } } + + private static String createSSHUrl(String url, String sshTemplate) { + // Based on Artemis' getSshCloneUrl method + // https://github.com/ls1intum/Artemis/blob/eb5b9bd4321d953217e902868ac9f38de6dd6c6f/src/main/webapp/app/shared/components/code-button/code-button.component.ts#L174 + return url.replaceAll("^\\w*://[^/]*?/(scm/)?(.*)$", sshTemplate + "$2"); + } + + private static final class PasswordPanel extends JPanel { + private final JPasswordField passwordField = new JPasswordField(); + + public PasswordPanel(String prompt) { + super(); + this.setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); + this.add(new JLabel(prompt)); + this.add(passwordField); + } + + public static Optional show(String title, String prompt) { + PasswordPanel panel = new PasswordPanel(prompt); + JOptionPane pane = new JOptionPane(panel, JOptionPane.QUESTION_MESSAGE, JOptionPane.OK_CANCEL_OPTION) { + @Override + public void selectInitialValue() { + panel.passwordField.requestFocusInWindow(); + } + }; + JDialog dialog = pane.createDialog(title); + dialog.setVisible(true); + + if (pane.getValue() != null && pane.getValue().equals(JOptionPane.OK_OPTION)) { + return Optional.of(new String(panel.passwordField.getPassword())); + } + return Optional.empty(); + } + } + + private static final class InteractiveCredentialsProvider extends CredentialsProvider { + private String passphrase; + + @Override + public boolean isInteractive() { + return true; + } + + @Override + public boolean supports(CredentialItem... items) { + return true; + } + + @Override + public boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem { + for (var item : items) { + if (item instanceof CredentialItem.YesNoType yesNoItem) { + int result = JOptionPane.showConfirmDialog(null, yesNoItem.getPromptText(), "Clone via SSH", JOptionPane.YES_NO_CANCEL_OPTION); + switch (result) { + case JOptionPane.YES_OPTION -> yesNoItem.setValue(true); + case JOptionPane.NO_OPTION -> yesNoItem.setValue(false); + case JOptionPane.CANCEL_OPTION -> { + return false; + } + } + } else if (item instanceof CredentialItem.Password passwordItem) { + if (this.passphrase == null) { + this.passphrase = PasswordPanel.show("Clone via SSH", passwordItem.getPromptText()).orElse(null); + } + + if (this.passphrase != null) { + passwordItem.setValueNoCopy(passphrase.toCharArray()); + } else { + return false; + } + } + } + return true; + } + } } diff --git a/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/User.java b/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/User.java index facf4b5..d876208 100644 --- a/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/User.java +++ b/src/main/java/edu/kit/kastel/sdq/artemis4j/grading/User.java @@ -30,6 +30,10 @@ public Optional getGitToken() { return Optional.ofNullable(this.dto.vcsAccessToken()); } + public Optional getGitSSHKey() { + return Optional.ofNullable(this.dto.sshPublicKey()); + } + public List getGroups() { return Collections.unmodifiableList(this.dto.groups()); } diff --git a/src/test/java/edu/kit/kastel/sdq/artemis4j/APIExampleTest.java b/src/test/java/edu/kit/kastel/sdq/artemis4j/APIExampleTest.java index 44259f3..36ac260 100644 --- a/src/test/java/edu/kit/kastel/sdq/artemis4j/APIExampleTest.java +++ b/src/test/java/edu/kit/kastel/sdq/artemis4j/APIExampleTest.java @@ -40,7 +40,7 @@ void testLogin() throws ArtemisClientException, IOException { // Fetch all courses, and get the first one (not the course with id 0!) // Network requests are generally only performed once when required, and the // results are cached - var course = connection.getCourses().get(0); + var course = connection.getCourseById(9); System.out.println("Course is " + course.getTitle()); // Check how many locks we hold across the entire course