From 21edd41975ad43f0dd32fc3f1717c28ad381bb70 Mon Sep 17 00:00:00 2001 From: Bob Hageman Date: Wed, 23 Aug 2023 15:29:46 +0200 Subject: [PATCH 01/22] feat: enable auto update build config vs code --- .vscode/settings.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d53ecaf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file From 7a4237987363e6c918c589296aecb8a03baf39c0 Mon Sep 17 00:00:00 2001 From: Bob Hageman Date: Thu, 24 Aug 2023 10:55:43 +0200 Subject: [PATCH 02/22] refactor: update gradle and dependencies, merge irma_server_common into codebase --- build.gradle | 36 +- gradle/wrapper/gradle-wrapper.properties | 3 +- .../privacybydesign/email/Client.java | 2 - .../email/EmailConfiguration.java | 4 +- .../privacybydesign/email/EmailProvider.java | 4 +- .../privacybydesign/email/EmailRestApi.java | 11 +- .../privacybydesign/email/EmailSender.java | 5 +- .../privacybydesign/email/EmailService.java | 2 +- .../privacybydesign/email/EmailTokens.java | 138 ++++++++ .../email/common/BaseConfiguration.java | 317 ++++++++++++++++++ src/main/resources/jetty-env.xml | 1 + .../email/EmailTokensTest.java | 71 ++++ 12 files changed, 560 insertions(+), 34 deletions(-) create mode 100644 src/main/java/foundation/privacybydesign/email/EmailTokens.java create mode 100644 src/main/java/foundation/privacybydesign/email/common/BaseConfiguration.java create mode 100644 src/test/java/foundation/privacybydesign/email/EmailTokensTest.java diff --git a/build.gradle b/build.gradle index 48b6048..227067e 100644 --- a/build.gradle +++ b/build.gradle @@ -2,9 +2,9 @@ group 'foundation.privacybydesign.email' version '1.1.0' apply plugin: 'war' -apply plugin: 'org.akhikhl.gretty' +apply plugin: 'org.gretty' -sourceCompatibility = 1.7 +sourceCompatibility = 11 buildscript { repositories { @@ -13,12 +13,11 @@ buildscript { } } dependencies { - classpath "gradle.plugin.org.akhikhl.gretty:gretty:1.4.2" + classpath "org.gretty:gretty:4.0.3" } } repositories { - mavenLocal() maven { url "https://credentials.github.io/repos/maven2/" } @@ -26,19 +25,26 @@ repositories { } dependencies { - compile 'org.glassfish.jersey.core:jersey-server:2.25' - compile 'org.glassfish.jersey.containers:jersey-container-servlet:2.25' - compile 'ch.qos.logback:logback-classic:1.1.7' - compile 'com.sun.mail:javax.mail:1.5.6' - - compile 'org.irmacard.api:irma_api_common:1.2.2' - compile 'foundation.privacybydesign.common:irma_server_common:0.3.2' - - testCompile group: 'junit', name: 'junit', version: '4.12' + implementation 'org.glassfish.jersey.core:jersey-server:3.0.0' + implementation 'org.glassfish.jersey.containers:jersey-container-servlet:3.0.0' + implementation 'org.glassfish.jersey.inject:jersey-hk2:3.0.0' + implementation 'ch.qos.logback:logback-classic:1.1.7' + implementation 'jakarta.mail:jakarta.mail-api:2.1.2' + + implementation 'jakarta.ws.rs:jakarta.ws.rs-api:3.1.0' + + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'com.google.code.gson:gson:2.8.9' + implementation 'org.apache.commons:commons-lang3:3.7' + implementation 'org.bouncycastle:bcpkix-jdk15on:1.70' + implementation 'org.bouncycastle:bcprov-jdk15on:1.67' + implementation 'jakarta.xml.bind:jakarta.xml.bind-api:3.0.1' + + implementation 'org.irmacard.api:irma_api_common:2.0.0' + + testImplementation group: 'junit', name: 'junit', version: '4.13.1' } gretty { contextConfigFile = file('src/main/resources/jetty-env.xml') - scanInterval = 10 - inplaceMode = "hard" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 53b9e38..5083229 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.9.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/foundation/privacybydesign/email/Client.java b/src/main/java/foundation/privacybydesign/email/Client.java index 4050174..4f805b2 100644 --- a/src/main/java/foundation/privacybydesign/email/Client.java +++ b/src/main/java/foundation/privacybydesign/email/Client.java @@ -2,8 +2,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - -import java.io.IOException; import java.util.HashMap; public class Client { diff --git a/src/main/java/foundation/privacybydesign/email/EmailConfiguration.java b/src/main/java/foundation/privacybydesign/email/EmailConfiguration.java index 6384327..94519f5 100644 --- a/src/main/java/foundation/privacybydesign/email/EmailConfiguration.java +++ b/src/main/java/foundation/privacybydesign/email/EmailConfiguration.java @@ -1,6 +1,6 @@ package foundation.privacybydesign.email; -import foundation.privacybydesign.common.BaseConfiguration; +import foundation.privacybydesign.email.common.BaseConfiguration; import io.jsonwebtoken.SignatureAlgorithm; import org.irmacard.api.common.util.GsonUtil; import org.slf4j.Logger; @@ -12,7 +12,7 @@ import java.util.HashMap; import java.util.Map; -public class EmailConfiguration extends BaseConfiguration { +public class EmailConfiguration extends BaseConfiguration { private static Logger logger = LoggerFactory.getLogger(Client.class); static EmailConfiguration instance; diff --git a/src/main/java/foundation/privacybydesign/email/EmailProvider.java b/src/main/java/foundation/privacybydesign/email/EmailProvider.java index 447ddfd..e478696 100644 --- a/src/main/java/foundation/privacybydesign/email/EmailProvider.java +++ b/src/main/java/foundation/privacybydesign/email/EmailProvider.java @@ -1,8 +1,6 @@ package foundation.privacybydesign.email; -import foundation.privacybydesign.common.email.EmailTokens; - -import javax.mail.internet.AddressException; +import jakarta.mail.internet.AddressException; /** * Test console application. Quite useless now, but was useful while writing diff --git a/src/main/java/foundation/privacybydesign/email/EmailRestApi.java b/src/main/java/foundation/privacybydesign/email/EmailRestApi.java index 2876bc1..8f454d3 100644 --- a/src/main/java/foundation/privacybydesign/email/EmailRestApi.java +++ b/src/main/java/foundation/privacybydesign/email/EmailRestApi.java @@ -1,7 +1,5 @@ package foundation.privacybydesign.email; -import foundation.privacybydesign.common.email.EmailTokens; -import foundation.privacybydesign.common.filters.RateLimit; import org.irmacard.api.common.ApiClient; import org.irmacard.api.common.CredentialRequest; import org.irmacard.api.common.issuing.IdentityProviderRequest; @@ -10,10 +8,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.mail.internet.AddressException; -import javax.ws.rs.*; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; +import jakarta.mail.internet.AddressException; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -93,7 +91,6 @@ public Response sendEmail(@FormParam("email") String email, @POST @Path("/send-email-token") @Produces(MediaType.TEXT_PLAIN) - @RateLimit public Response sendEmailToken(@FormParam("email") String emailAddress, @FormParam("language") String language) { EmailConfiguration conf = EmailConfiguration.getInstance(); diff --git a/src/main/java/foundation/privacybydesign/email/EmailSender.java b/src/main/java/foundation/privacybydesign/email/EmailSender.java index 27449f2..ef35b0f 100644 --- a/src/main/java/foundation/privacybydesign/email/EmailSender.java +++ b/src/main/java/foundation/privacybydesign/email/EmailSender.java @@ -4,9 +4,8 @@ import org.slf4j.LoggerFactory; import java.util.Properties; -import javax.mail.*; -import javax.mail.internet.*; - +import jakarta.mail.*; +import jakarta.mail.internet.*; /** * Simple class to send emails. Mail host/port/auth is configured in diff --git a/src/main/java/foundation/privacybydesign/email/EmailService.java b/src/main/java/foundation/privacybydesign/email/EmailService.java index af9ccf3..50a41f4 100644 --- a/src/main/java/foundation/privacybydesign/email/EmailService.java +++ b/src/main/java/foundation/privacybydesign/email/EmailService.java @@ -6,7 +6,7 @@ import org.glassfish.jersey.server.ResourceConfig; -import javax.ws.rs.*; +import jakarta.ws.rs.*; @ApplicationPath("/") public class EmailService extends ResourceConfig { diff --git a/src/main/java/foundation/privacybydesign/email/EmailTokens.java b/src/main/java/foundation/privacybydesign/email/EmailTokens.java new file mode 100644 index 0000000..32605e0 --- /dev/null +++ b/src/main/java/foundation/privacybydesign/email/EmailTokens.java @@ -0,0 +1,138 @@ +package foundation.privacybydesign.email; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Mac; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import javax.crypto.spec.SecretKeySpec; +import jakarta.xml.bind.DatatypeConverter; +/** + * Create and verify tokens that can be used in email verification messages. + * + * Based on this idea: + * https://aykevl.nl/2015/01/south-stateless-authenticated-sessions-http-golang + * In short, a token has the format payload:timestamp:signature + * where the payload can be any email address (email addresses may not + * contain colons according to the HTML5 field so it + * should be fine). The timestamp is the creation timestamp. The signature is + * over the payload:timestamp part. This way it is relatively easy to expire + * tokens (set a different validity) or revoke (change the key). + * + * The tokens aren't too long, just 55 bytes + the payload. And by using the + * URL version of base64 the potentially problematic '/' and '+' characters + * are avoided so tokens can be easily put in a URL without escaping. + */ +public class EmailTokens { + private static Logger logger = LoggerFactory.getLogger(EmailTokens.class); + private static String SIGNING_ALGORITHM = "HmacSHA256"; + + private Mac mac; + private long tokenValidity; + + public EmailTokens(String signingKey, long tokenValidity) { + this.tokenValidity = tokenValidity; + + // HMAC calculated using this sample: + // https://gist.github.com/ishikawa/88599/3195bdeecabeb38aa62872ab61877aefa6aef89e + SecretKeySpec key = new SecretKeySpec(signingKey.getBytes(), SIGNING_ALGORITHM); + try { + mac = Mac.getInstance(SIGNING_ALGORITHM); + mac.init(key); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + // This should not normally happen + throw new RuntimeException("Unknown key?"); + } + } + + /** + * Create a token: a value with a creation time and signature. + * Can be used to create e.g. authentication tokens. + */ + public String createToken(String value) { + // We could also use a bigger radix for the timestamp to make it + // smaller (for example, a radix of 36 uses only 6 bytes instead of 10). + String timestamp = Long.toString(System.currentTimeMillis() / 1000); + + return value + ":" + timestamp + ":" + signToken(value + ":" + timestamp); + } + + /** + * Sign a token (value+timestamp) with a HMAC. Output the digest of the + * HMAC as base64 url-encoded string. + */ + private String signToken(String token) { + // See https://aykevl.nl/2015/01/south-stateless-authenticated-sessions-http-golang + // for background on the system. + byte[] digestBytes = mac.doFinal(token.getBytes()); + String digest = DatatypeConverter.printBase64Binary(digestBytes); + digest = digest.substring(0, 43); // strip tailing '=' + digest = digest // convert to base64 URL encoding + .replace('+', '-') + .replace('/', '_'); + + return digest; + } + + /** + * Verify the token. If it is verified and not expired, return the value. + * Otherwise, return null. + */ + public String verifyToken(String token) { + // Parse token + String[] parts = token.split(":"); + if (parts.length != 3) { + // invalid syntax + logger.error("Token {} does not have 3 parts", token); + return null; + } + String value = parts[0]; + String timestamp = parts[1]; + String digestText = parts[2]; + + long creationTime; + try { + creationTime = Long.parseLong(timestamp); + } catch (NumberFormatException e) { + // Invalid syntax + logger.error("Token {} has non-integer creation time", token); + return null; + } + + // Verify expired tokens + long currentTime = System.currentTimeMillis() / 1000; + if (currentTime > creationTime+tokenValidity) { + // Token is no longer valid. + logger.error("Token {} has expired", token); + return null; + } + + // Verify signature + String calculatedDigestText = signToken(value + ":" + timestamp); + if (isEqualsConstantTime(digestText.toCharArray(), + calculatedDigestText.toCharArray())) { + return value; + } else { + logger.error("Token {} has invalid HMAC", token); + return null; + } + } + + /** + * Compare two byte arrays in constant time. + */ + public static boolean isEqualsConstantTime(char[] a, char[] b) { + if (a.length != b.length) { + return false; + } + + byte result = 0; + for (int i = 0; i < a.length; i++) { + result |= a[i] ^ b[i]; + } + return result == 0; + } + +} \ No newline at end of file diff --git a/src/main/java/foundation/privacybydesign/email/common/BaseConfiguration.java b/src/main/java/foundation/privacybydesign/email/common/BaseConfiguration.java new file mode 100644 index 0000000..31fe9f3 --- /dev/null +++ b/src/main/java/foundation/privacybydesign/email/common/BaseConfiguration.java @@ -0,0 +1,317 @@ +package foundation.privacybydesign.email.common; + +import com.google.gson.JsonSyntaxException; +import org.apache.commons.lang3.SystemUtils; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemReader; +import org.irmacard.api.common.util.GsonUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.security.*; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.HashMap; + +public class BaseConfiguration { + // Override these in a static {} block + public static Class> clazz; + public static Logger logger = LoggerFactory.getLogger(BaseConfiguration.class); + public static String filename = "config.json"; + public static String environmentVarPrefix = "IRMA_CONF_"; + public static String confDirEnvironmentVarName = "IRMA_CONF"; + public static String confDirName; + public static boolean printOnLoad = false; + public static boolean testing = false; + + // Return this from a static getInstance() + public static BaseConfiguration instance; + private static URI confPath; + + + public static void load() { + try { + String json = new String(getResource(filename)); + instance = GsonUtil.getGson().fromJson(json, clazz); + logger.info("Using configuration directory: " + BaseConfiguration.getConfigurationDirectory().toString()); + } catch (IOException|JsonSyntaxException e) { + logger.info("WARNING: could not load configuration file. Using default values or environment vars"); + instance = GsonUtil.getGson().fromJson("{}", clazz); + } + instance.loadEnvVars(); + + if (printOnLoad) { + logger.info("Configuration:"); + logger.info(instance.toString()); + } + } + + public static BaseConfiguration getInstance() { + if (instance == null) + load(); + return instance; + } + + public static FileInputStream getResourceStream(String filename) throws IOException { + return new FileInputStream(new File(getConfigurationDirectory().resolve(filename))); + } + + public static byte[] getResource(String filename) throws IOException { + return convertSteamToByteArray(getResourceStream(filename), 2048); + } + + public static byte[] convertSteamToByteArray(InputStream stream, int size) throws IOException { + byte[] buffer = new byte[size]; + ByteArrayOutputStream os = new ByteArrayOutputStream(); + + int line; + while ((line = stream.read(buffer)) != -1) { + os.write(buffer, 0, line); + } + stream.close(); + + os.flush(); + os.close(); + return os.toByteArray(); + } + + public static PublicKey getPublicKey(String filename) throws KeyManagementException { + try { + byte[] bytes = filename.endsWith(".pem") ? readPemFile(filename) : getResource(filename); + return decodePublicKey(bytes); + } catch (IOException e) { + throw new KeyManagementException(e); + } + } + + private static byte[] readPemFile(String filename) throws FileNotFoundException, IOException { + PemReader pemReader = new PemReader(new InputStreamReader(getResourceStream(filename))); + PemObject pemObject = pemReader.readPemObject(); + pemReader.close(); + return pemObject.getContent(); + } + + public static PublicKey decodePublicKey(byte[] bytes) throws KeyManagementException { + try { + if (bytes == null || bytes.length == 0) + throw new KeyManagementException("Could not read public key"); + X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes); + return KeyFactory.getInstance("RSA").generatePublic(spec); + } catch (NoSuchAlgorithmException|InvalidKeySpecException e) { + throw new KeyManagementException(e); + } + } + + public static PrivateKey getPrivateKey(String filename) throws KeyManagementException { + try { + return decodePrivateKey(getResource(filename)); + } catch (IOException e) { + throw new KeyManagementException(e); + } + } + + public static PrivateKey decodePrivateKey(byte[] rawKey) throws KeyManagementException { + try { + if (rawKey == null || rawKey.length == 0) + throw new KeyManagementException("Could not read private key"); + + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(rawKey); + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } catch (NoSuchAlgorithmException |InvalidKeySpecException e) { + throw new KeyManagementException(e); + } + } + + /** + * Override configuration with environment variables, if set + * Uses reflection to set variables, because otherwise it would be impossible to set all variable at once in a loop + */ + public void loadEnvVars() { + for (Field f : BaseConfiguration.clazz.getDeclaredFields()) { + if ( Modifier.isTransient(f.getModifiers()) || Modifier.isStatic(f.getModifiers())) { + // Skip transient and static fields + continue; + } + + Object envValue = getEnv(environmentVarPrefix + f.getName(), f.getType()); + if (envValue != null) { + try { + f.set(this, envValue); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + } + } + } + + /** + * Obtain an environment variable and parse it to the right type + * @param confEntry name of environment variable + * @param cls class to be parsed into (either Integer, Boolean, String, HashMap) + * @param type of the variable + * @return a parsed variable in the right type (T) or null if environment variable isn't set + */ + public static T getEnv(String confEntry, Class cls) { + confEntry = confEntry.toUpperCase(); + String env = System.getenv(confEntry); + if (env== null || env.length() == 0) { + return null; + } + + T overrideValue; + + try { + if (cls == int.class) { + try { + Integer parsed = Integer.parseInt(env); + overrideValue = cls.cast(parsed); + } catch (NumberFormatException e) { + logger.warn("Could not parse config entry as int: " + confEntry + " with value: " + env); + return null; + } + } else if (cls == boolean.class) { + Boolean parsed = Boolean.parseBoolean(env); + overrideValue = cls.cast(parsed); + } else if (cls == String.class) { + overrideValue = cls.cast(env); + } else if (cls == HashMap.class){ // Try to parse as hashmap for authorized_??? entries + try { + overrideValue = cls.cast(GsonUtil.getGson().fromJson(env, cls)); + } catch (JsonSyntaxException e) { + logger.warn("Could not parse config entry as json: " + confEntry + " with value: " + env); + return null; + } + } else { + throw new IllegalArgumentException("Invalid class specified, must be one of: Integer, Boolean, String, HashMap"); + } + } catch (ClassCastException e){ + logger.warn("Could not cast config entry: " + confEntry + " with value: " + env); + return null; + } + + logger.info("Overriding config entry " + confEntry + " with value: " + env); + return overrideValue; + } + + /** + * If a path was set in the $confDirEnvironmentVarName environment variable, return it + */ + public static URI getEnvironmentVariableConfDir() throws URISyntaxException { + String envDir = System.getenv(confDirEnvironmentVarName); + if (envDir == null || envDir.length() == 0) + return null; + return pathToURI(envDir, true); + } + + /** + * Returns true if the specified path is a valid configuration directory. A directory + * is considered a valid configuration directory if it contains a file called $filename. + */ + public static boolean isConfDirectory(URI candidate) { + return candidate != null && new File(candidate.resolve(filename)).isFile(); + } + + /** + * Get the path to the Java resources directory, i.e., src/main/resources or src/test/resources; + * note that it must contain the file called $filename or "config.test.json" for this to work + */ + public static URI GetJavaResourcesDirectory() throws URISyntaxException { + // The only way to actually get the resource folder, as opposed to the classes folder, + // seems to be to ask for an existing file or directory within the resources. That is, + // BaseConfiguration.class.getClassLoader().getResource("/") or variants thereof + // give an incorrect path. + String testfile = BaseConfiguration.testing ? "config.test.json" : filename; + URL url = BaseConfiguration.class.getClassLoader().getResource(testfile); + if (url != null) // Construct an URI of the parent path + return pathToURI(new File(url.getPath()).getParent(), true); + else + return null; + } + + public static URI pathToURI(String path, boolean trailingSlash) throws URISyntaxException { + if (trailingSlash && !path.endsWith("/")) + path = path + "/"; + if (SystemUtils.IS_OS_WINDOWS) + path = path.replace("\\", "/"); + // path might contain file: or file:/ or file:// or file:/// or none of them + // Just throw them all away so we can fix it properly + path = path.replaceFirst("^file:/*", ""); + if (SystemUtils.IS_OS_WINDOWS) // Sometimes we get file:///C/blah, that doesn't work + path = path.replaceFirst("^(\\w)/", "$1:/"); + return new URI("file:///" + path); + } + + /** + * Get the configuration directory. + * @throws IllegalStateException If no suitable configuration directory was found + * @throws IllegalArgumentException If the path from the $confDirEnvironmentVarName environment variable was + * not a valid path + */ + public static URI getConfigurationDirectory() throws IllegalStateException, IllegalArgumentException { + if (confPath != null) + return confPath; + + try { + // If we're running unit tests, only accept src/test/resources + URI resourcesCandidate = GetJavaResourcesDirectory(); + if (BaseConfiguration.testing) { + if (resourcesCandidate != null) { + logger.info("Running tests: taking src/test/resources as configuration directory"); + confPath = resourcesCandidate; + return confPath; + } + else { + throw new IllegalStateException("No configuration found in in src/test/resources. " + + "(Have you run `git submodule init && git submodule update`?)"); + } + } + + // If a path was given in the $confDirEnvironmentVarName environment variable, prefer it + URI envCandidate = getEnvironmentVariableConfDir(); + if (envCandidate != null) { + if (isConfDirectory(envCandidate)) { + logger.info("Taking configuration directory specified by environment variable " + confDirEnvironmentVarName); + confPath = envCandidate; + return confPath; + } else { + // If the user specified an incorrect path (s)he will want to know, so bail out here + throw new IllegalArgumentException("Specified path in " + confDirEnvironmentVarName + + " is not a valid configuration directory"); + } + } + + // See if a number of other fixed candidates are suitable + ArrayList candidates = new ArrayList<>(4); + candidates.add(resourcesCandidate); + if (confDirName != null) { + candidates.add(pathToURI("/etc/" + confDirName, true)); + candidates.add(pathToURI("C:/" + confDirName, true)); + candidates.add(pathToURI(System.getProperty("user.home")+"/"+confDirName, true)); + } + + for (URI candidate : candidates) { + if (isConfDirectory(candidate)) { + confPath = candidate; + return confPath; + } + } + + throw new IllegalStateException("No valid configuration directory found"); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + } + + @Override + public String toString() { + return GsonUtil.getGson().toJson(this); + } +} \ No newline at end of file diff --git a/src/main/resources/jetty-env.xml b/src/main/resources/jetty-env.xml index 60bc1c8..4d9ca44 100644 --- a/src/main/resources/jetty-env.xml +++ b/src/main/resources/jetty-env.xml @@ -1,4 +1,5 @@ +