Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ Thumbs.db
.DS_Store
.gradle
build/
bin/
target/
out/
.micronaut/
Expand Down
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ dependencies {
implementation("io.micronaut.picocli:micronaut-picocli")
implementation("io.micronaut.serde:micronaut-serde-jackson")
implementation("io.micronaut:micronaut-retry")
implementation("org.simplejavamail:simple-java-mail:8.12.6")
implementation("org.jsoup:jsoup:1.21.2")
testImplementation("net.datafaker:datafaker:2.5.1")
compileOnly("org.projectlombok:lombok")
runtimeOnly("ch.qos.logback:logback-classic")
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/org/justserve/cli/JustServeCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
import org.justserve.cli.command.BaseCommand;
import org.justserve.cli.command.GetTempPassword;
import org.justserve.cli.command.MakeOrgAdmin;
import org.justserve.cli.command.UnReassignProjects;
import org.justserve.cli.util.JustServeVersionProvider;
import picocli.CommandLine.Command;
import picocli.CommandLine.ParameterException;
import picocli.jansi.graalvm.AnsiConsole;

@Command(subcommands = {GetTempPassword.class, MakeOrgAdmin.class},
@Command(subcommands = {GetTempPassword.class, MakeOrgAdmin.class, UnReassignProjects.class},
mixinStandardHelpOptions = true,
name = "justserve", versionProvider = JustServeVersionProvider.class,
description = "justserve-cli is a terminal tool to help specialists and admin using JustServe")
Expand Down
6 changes: 3 additions & 3 deletions src/main/java/org/justserve/cli/command/BaseCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ public class BaseCommand implements ConsoleOutput {
@Value("${justserve.token}")
String token;

boolean validateToken() {
boolean isTokenInvalid() {
if ("i-need-to-be-defined".equals(token) || null == token) {
printError(("NO AUTHENTICATION PROVIDED" + System.lineSeparator() +
"The Authentication token is not assigned as an environment variable." + System.lineSeparator() +
"Please define the environment variable \"JUSTSERVE_TOKEN\" and try again."));
return false;
return true;
}
return true;
return false;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public class GetTempPassword extends BaseCommand implements Runnable {

@Override
public void run() {
if (!validateToken()) {
if (isTokenInvalid()) {
return;
}
HttpResponse<String> response;
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/org/justserve/cli/command/MakeOrgAdmin.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public class MakeOrgAdmin extends BaseCommand implements Runnable {

@Override
public void run() {
if (!validateToken()) {
if (isTokenInvalid()) {
return;
}
DynamicRoutingClient dynamicRoutingClient = dynamicRoutingClientProvider.get();
Expand Down
123 changes: 123 additions & 0 deletions src/main/java/org/justserve/cli/command/UnReassignProjects.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package org.justserve.cli.command;

import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import jakarta.mail.MessagingException;
import lombok.extern.slf4j.Slf4j;
import org.justserve.cli.util.JustServeEmailParserError;
import org.justserve.client.ProjectClient;
import org.justserve.model.GetProjectRequest;
import org.justserve.model.Project;
import org.justserve.model.ReassignProjectRequest;
import org.justserve.util.EmailParser;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Map;
import java.util.UUID;

import static org.justserve.cli.util.JustServePrinter.printError;
import static org.justserve.cli.util.JustServePrinter.printNormal;

@Slf4j
@Command(name = "unReassignProjects", description = "Reassigns projects from an email file to a user", mixinStandardHelpOptions = true)
public class UnReassignProjects extends BaseCommand implements Runnable {

@Option(names = {"--user", "-u"}, description = "the user who will be assigned the projects", required = true)
private UUID userID;

@Option(names = {"--file", "-f"}, description = "the .eml file containing the projects to reassign", required = true)
private File emlFile;

@Inject
Provider<ProjectClient> projectClientProvider;

@Override
public void run() {
if (isTokenInvalid()) {
return;
}

if (!emlFile.exists() || !emlFile.isFile()) {
printError("The provided file does not exist or is not a file: " + emlFile.getAbsolutePath());
return;
}

String emlContent;
try {
emlContent = Files.readString(emlFile.toPath());
} catch (IOException e) {
printError("Failed to read file: " + emlFile.getAbsolutePath());
log.atError().setCause(e).log("Error reading file");
return;
}

Map<String, UUID> projects;
try {
projects = EmailParser.getProjects(emlContent);
} catch (MessagingException | IOException | JustServeEmailParserError e) {
printError("Failed to parse email file: " + e.getMessage());
log.atError().setCause(e).log("Error parsing email file");
return;
}

if (projects.isEmpty()) {
printNormal("No projects found in the email file.");
return;
}


ProjectClient client = projectClientProvider.get();
int successCount = 0;

for (Map.Entry<String, UUID> entry : projects.entrySet()) {
String projectName = entry.getKey();
UUID projectId = entry.getValue();
GetProjectRequest getProjectRequest = new GetProjectRequest();
Project project;
try {
project = client.getProject(projectId, " ", getProjectRequest).body();
} catch (HttpClientResponseException | NullPointerException e) {
printError("Failed to get project " + projectName + " (" + projectId + ")");
log.atError().setCause(e).log("Error getting project");
continue;
}
if (null == project.getProjectOwnerUserId()) {
warning(String.format("Project %s (%s) has no owner", projectName, projectId));
log.warn("Project {} ({}) has no owner", projectName, projectId);
continue;
}
if (project.getProjectOwnerUserId().equals(userID)) {
log.warn("Project {} ({}) is already assigned to user {}", projectName, projectId, userID);
continue;
}

try {
ReassignProjectRequest reassignProjectRequest = new ReassignProjectRequest(userID, project.getProjectOwnerUserId());
log.atTrace().log("Reassigning project {} ({}) to user {}", projectName, projectId, userID);
HttpResponse<Object> reassignResponse = client.reassignProject(projectId, reassignProjectRequest);
if (reassignResponse.status() == HttpStatus.OK) {
printNormal("Successfully reassigned project %s (%s) to user %s", projectName, projectId, userID);
log.atTrace().log("received api response status: {}", reassignResponse.status());
successCount++;
continue;
}
printError("Failed to reassign project " + projectName + " (" + projectId + ") to user " + userID +
". Expected HTTP Status 'OK', but got " + reassignResponse.status());
log.atError().log("Failed to reassign project {} ({}) to user {}. Expected HTTP Status 'OK', but got {}",
projectName, projectId, userID, reassignResponse.status());
} catch (HttpClientResponseException e) {
printError("Failed to reassign project " + projectName + " (" + projectId + ") to user " + userID);
log.atError().setCause(e).log("Error response from API: {}", e.getResponse().body());
}
}
printNormal("Successfully reassigned %d projects to user %s", successCount, userID);
log.atTrace().log("Finished reassigning projects to user {}", userID);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.justserve.cli.util;

public class JustServeEmailParserError extends Exception {
public JustServeEmailParserError(String message) {
super(message);
}
}
142 changes: 142 additions & 0 deletions src/main/java/org/justserve/util/EmailParser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package org.justserve.util;

import jakarta.mail.MessagingException;
import jakarta.mail.Multipart;
import jakarta.mail.Part;
import jakarta.mail.Session;
import jakarta.mail.internet.MimeMessage;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Node;
import org.jsoup.select.Elements;
import org.justserve.cli.util.JustServeEmailParserError;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.Pattern;

public class EmailParser {


/**
* Parses an EML format string, extracts the HTML body, verifies it's a JustServe generated email,
* and then extracts project names and their corresponding UUIDs.
*
* @param emlFileContent The content of the EML file as a String.
* @return A map where keys are project names (String) and values are project UUIDs.
* @throws MessagingException If there is an issue with parsing the MimeMessage.
* @throws IOException If an I/O error occurs during stream processing.
* @throws JustServeEmailParserError If the email does not contain an HTML body, is not a JustServe generated email,
* or the HTML structure does not conform to the expected format for extracting projects.
*/
public static Map<String, UUID> getProjects(String emlFileContent) throws MessagingException, IOException, JustServeEmailParserError {
return getProjects(parse(emlFileContent));
}

/**
* Parses an EML format string into a Jsoup Document, ensuring it's a JustServe generated email.
*
* @param emlFileContent The content of the EML file as a String.
* @return A Jsoup Document representing the HTML body of the email.
* @throws MessagingException If there is an issue with parsing the MimeMessage.
* @throws IOException If an I/O error occurs during stream processing.
* @throws JustServeEmailParserError If the email does not contain an HTML body or is not a JustServe generated email.
*/
public static Document parse(String emlFileContent) throws MessagingException, IOException, JustServeEmailParserError {
Properties props = new Properties();
Session session = Session.getDefaultInstance(props, null);
try (InputStream is = new ByteArrayInputStream(emlFileContent.getBytes(StandardCharsets.UTF_8))) {
MimeMessage message = new MimeMessage(session, is);
String htmlBody = getHtmlBody(message);
if (htmlBody == null) {
throw new JustServeEmailParserError("Email does not contain an HTML body.");
}
Document doc = Jsoup.parse(htmlBody);
if (!isJustServeGeneratedEmail(doc)) {
throw new JustServeEmailParserError("Email is not a JustServe generated email.");
}
return doc;
}
}

/**
* Extracts project names and their corresponding UUIDs for project ids on JustServe.
* The Document is expected to represent an HTML email body from an automated JustServe email regarding reassigned projects
*
* @param doc The Jsoup Document containing the HTML structure of the email.
* @return A map where keys are project names (String) and values are project UUIDs.
* @throws JustServeEmailParserError If the HTML structure does not conform to the expected format for extracting projects.
*/
public static Map<String, UUID> getProjects(Document doc) throws JustServeEmailParserError {
Map<String, UUID> projects = new HashMap<>();
Collection<String> errors = new ArrayList<>();
doc.selectXpath("//li").forEach(element -> {
List<Node> children = element.childNodes();
if (children.size() != 1 || children.getFirst().childNodes().size() != 1) {
errors.add(String.format("Expected 1 child node (with another single child node) in html list, but " + "found %d in list element with %d children", children.size(), children.getFirst().childNodes().size()));
}
projects.put(children.getFirst().childNode(0).toString(), getProjectIDFromUglyUrl(children.getFirst().attr("href")));
});
if (!errors.isEmpty()) {
throw new JustServeEmailParserError(String.join("\n", errors));
}
return projects;
}

/**
* Checks that the email contains an image whose source is the logo hosted in justserve's static assets.
*
* @param doc html content which may or may not have a justserve logo
* @return true if the html contains the target image, false if not.
*/
private static boolean isJustServeGeneratedEmail(Document doc) {
Elements elements = doc.selectXpath("//img[@src='https://static-assets.justserve.org/images/static/email/justserve-logo-title.gif']");
return !elements.isEmpty();
}


/**
* Recursively extracts the HTML body from a given {@link Part} of an email.
*
* @param part The email part to process.
* @return The HTML content as a String, or null if no HTML part is found.
* @throws MessagingException If there is an issue with the messaging system.
* @throws IOException If an I/O error occurs.
*/
private static String getHtmlBody(Part part) throws MessagingException, IOException {
if (part.isMimeType("text/html")) {
return (String) part.getContent();
}

if (part.isMimeType("multipart/*")) {
Multipart multipart = (Multipart) part.getContent();
for (int i = 0; i < multipart.getCount(); i++) {
Part bodyPart = multipart.getBodyPart(i);
String html = getHtmlBody(bodyPart);
if (html != null) {
return html;
}
}
}
return null;
}

/**
* gets the project ID from a url, which url is wrapped in proofpoint's URL defense service, and thus looks ugly.
*
* @param uglyUrl long url with an encoded justserve project url.
* @return the UUID of the project contained in the URL. returns null if the URL does
* not contain the 'www.justserve.org*2Fprojects*2F' string.
*/
static UUID getProjectIDFromUglyUrl(String uglyUrl) {
String start = "www.justserve.org*2Fprojects*2F";
if (!uglyUrl.contains(start)) {
return null;
}
String uuid = uglyUrl.split(Pattern.quote(start))[1].split(Pattern.quote("/"))[0];
return UUID.fromString(uuid);
}
}
Loading
Loading