net.yadaframework.core.YadaConfiguration Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of yadaweb Show documentation
Show all versions of yadaweb Show documentation
Some useful tasks for the Yada Framework
package net.yadaframework.core;
import java.io.File;
import java.nio.file.Path;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import org.apache.commons.configuration2.ConfigurationUtils;
import org.apache.commons.configuration2.ImmutableHierarchicalConfiguration;
import org.apache.commons.configuration2.builder.combined.CombinedConfigurationBuilder;
import org.apache.commons.configuration2.builder.combined.ReloadingCombinedConfigurationBuilder;
import org.apache.commons.configuration2.ex.ConfigurationException;
import org.apache.commons.lang3.LocaleUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.format.Formatter;
import jakarta.servlet.http.HttpServletRequest;
import net.yadaframework.exceptions.YadaConfigurationException;
import net.yadaframework.exceptions.YadaInternalException;
import net.yadaframework.exceptions.YadaInvalidValueException;
import net.yadaframework.persistence.entity.YadaClause;
import net.yadaframework.raw.YadaIntDimension;
import net.yadaframework.web.YadaViews;
/**
* Classe che estende CombinedConfiguration aggiungendo metodi di gestione della configurazione specifici.
*/
public abstract class YadaConfiguration {
private static Logger log = LoggerFactory.getLogger(YadaConfiguration.class);
protected ImmutableHierarchicalConfiguration configuration;
protected CombinedConfigurationBuilder builder;
// Cached values
// Questi valori li memorizzo perchè probabilmente verranno controllati
// ad ogni pageview e comunque non mi aspetto che cambino a runtime
private String uploadsDir = null;
private String contentUrl = null;
private String contentName = null;
private String environment = null;
private String version = null;
private String yadaVersion = null;
private String releaseDate = null;
private String build = null;
private String logoImage = null;
private Boolean production = null;
private Boolean development = null;
private Boolean beta = null;
private Boolean alpha = null;
private Map roleIdToKeyMap = null;
private Map roleKeyToIdMap = null;
private Object roleMapMonitor = new Object();
private String googleClientId = null;
private String googleSecret = null;
private String facebookAppId = null;
private String facebookPageId = null;
private String facebookSecret = null;
private String serverAddress = null;
private String webappAddress = null;
private int facebookType = -1;
private int googleType = -1;
private String tagReservedPrefix = null;
private int tagMaxNum = -1;
private int tagMaxSuggested = -1;
private int tagFilterMax = -1;
private int maxPwdLen = -1;
private int minPwdLen = -1;
private String errorPageForward = null;
private List locales = null;
private Set localeSet = null;
private List localeObjects = null;
private Map languageToCountry = null;
private Boolean localeAddCountry = null;
private Boolean localePathVariableEnabled = null;
private Locale defaultLocale = null;
private boolean defaultLocaleChecked = false;
private Map>> localSetCache = new HashMap<>(); // Deprecated
private String targetImageExtension=null;
private String preserveImageExtensions=null;
private String defaultNotifyModalView = null;
private File uploadsFolder = null;
private File tempFolder = null;
private String googleApiKey = null;
private Integer bootstrapVersion = null;
/**
* Copy the already initialised configuration to a different instance
* @param yadaConfiguration a subclass of YadaConfiguration
*/
public void copyTo(YadaConfiguration yadaConfiguration) {
yadaConfiguration.builder = this.builder;
yadaConfiguration.configuration = this.configuration;
}
/**
* Returns the configured timeout for asynchronous requests in seconds.
* Equivalent to the Tomcat parameter asyncTimeout, but more effective (the application-defined parameter takes precedence).
* @return the configured timeout in minutes or 0 for the default
*/
public int getAsyncTimeoutMinutes() {
return configuration.getInt("config/asyncTimeoutMinutes", 0);
}
/**
* @return true if the embedded db should be used instead of the external MySQL
*/
public boolean isDatabaseEnabled() {
return configuration.getBoolean("config/database/@enabled", true);
}
/**
* @return true if the embedded db should be used instead of the external MySQL
*/
public boolean isUseEmbeddedDatabase() {
return configuration.getBoolean("config/database/embedded/@enabled", false);
}
/**
* @return the location of the data folder for the embedded database
*/
public String getEmbeddedDatabaseDataDir() {
return configuration.getString("config/database/embedded/datadir", "dbembedded");
}
/**
* Returns a pointer to the sql file configured for loading the embedded database at startup
* @return the File to use for reading or null if the file is not configured or not readable
*/
public File getEmbeddedDatabaseSourceSql() {
String sourceSqlPath = configuration.getString("config/database/embedded/sourceSql", null);
if (sourceSqlPath!=null) {
File result = new File(sourceSqlPath);
if (result.canRead()) {
return result;
}
}
log.debug("No source sql to load at startup");
return null;
}
/**
* Given three strings (e.g. classes) returns the one that corresponds to the
* configured Bootstrap version, from 3 to 5
* @param classForB3
* @param classForB4
* @param classForB5
* @return the argument that corresponds to the configured Bootstrap version, or the last one.
*/
public String getForB3B4B5(String classForB3, String classForB4, String classForB5) {
switch (getBootstrapVersion()) {
case 3: {
return classForB3;
}
case 4: {
return classForB4;
}
case 5: {
return classForB5;
}
}
return classForB5;
}
public boolean isB5() {
return getBootstrapVersion()==5;
}
public boolean isB4() {
return getBootstrapVersion()==4;
}
public boolean isB3() {
return getBootstrapVersion()==3;
}
/**
* The configured bootstrap version may be used to return the correct html for modals etc.
* @return the configured bootstrap version, defaults to 5
*/
public int getBootstrapVersion() {
if (bootstrapVersion==null) {
bootstrapVersion = configuration.getInt("config/bootstrapVersion", 5);
}
return bootstrapVersion;
}
/**
* Returns the configured Date Formatter. Use like <dateFormatter>net.yadaframework.components.YadaDateFormatter</dateFormatter> in config.
* Defaults to DefaultFormattingConversionService when not configured.
* @return
*/
public Formatter getDateFormatter() {
String dateFormatterClass = configuration.getString("config/dateFormatter", null);
try {
if (dateFormatterClass!=null) {
return (Formatter) Class.forName(dateFormatterClass).newInstance();
}
} catch (Exception e) {
log.error("Can't make instance of {} (ignored)", dateFormatterClass, e);
}
return null;
}
/**
* Quick way to specify a new value for all embedded tomcat ports:
* the offset will be added to the default value.
* Some values may generate errors for overlapping ports e.g. an offset of 71 would set the AJP port
* to 8080 that may be already in use by the HTTP port of another instance with the offset at 0.
*/
public int getTomcatPortOffset() {
return configuration.getInt("config/tomcat/ports/offset", 0);
}
public int getTomcatHttpPort() {
return configuration.getInt("config/tomcat/ports/http", 8080) + getTomcatPortOffset();
}
public int getTomcatHttpsPort() {
return configuration.getInt("config/tomcat/ports/https", 8443) + getTomcatPortOffset();
}
public int getTomcatAjpPort() {
return configuration.getInt("config/tomcat/ports/ajp", 8009) + getTomcatPortOffset();
}
public int getTomcatAjpRedirectPort() {
return configuration.getInt("config/tomcat/ports/ajpRedirect", 8443) + getTomcatPortOffset();
}
public int getTomcatShutdownPort() {
return configuration.getInt("config/tomcat/ports/shutdown", 8005) + getTomcatPortOffset();
}
public File getTomcatKeystoreFile() {
return new File(configuration.getString("config/tomcat/keystore/file", "/srv/devtomcatkeystore"));
}
public String getTomcatKeystorePassword() {
return configuration.getString("config/tomcat/keystore/password", "changeit");
}
/**
* The maximum size in bytes of the POST which will be handled by
* the container FORM URL parameter parsing. The limit can be disabled
* by setting this attribute to a value less than zero. If not specified,
* this attribute is set to 2097152 (2 MiB).
*/
public int getTomcatMaxPostSize() {
return configuration.getInt("config/tomcat/maxPostSize", 2097152);
}
/**
* Google api key (for Maps etc) read from "security.properties"
* @return
*/
public String getGoogleApiKey() {
if (googleApiKey==null) {
// Does not start with "config/" because it is in security.properties
googleApiKey = configuration.getString("google/api/key", "");
}
return googleApiKey;
}
/**
* Gets the value of any boolean config key defined in the /config/local configuration
* file (it should reside on the developers computer in a personal folder, not shared).
* When not defined or not a boolean, the value is false.
* Only available in dev env.
* @param name
* @return
*/
public boolean isLocalFlag(String name) {
if (!isDevelopmentEnvironment()) {
return false;
}
return configuration.getBoolean("config/local/"+name, false);
}
/**
* Gets the value of any config key defined in the /config/local configuration
* file (it should reside on the developers computer in a personal folder, not shared).
* When not defined, the value is an empty string.
* Only available in dev env.
* @param name
* @return
*/
public Object getLocalConfig(String name) {
if (!isDevelopmentEnvironment()) {
return "";
}
return configuration.getString("config/local/"+name, "");
}
/**
* Returns the configured path for the notification modal.
* The configuration path is config/paths/notificationModalView
* @return
*/
public String getNotifyModalView() {
if (defaultNotifyModalView==null) {
defaultNotifyModalView = configuration.getString("config/paths/notificationModalView", getForB3B4B5(YadaViews.AJAX_NOTIFY_B3, YadaViews.AJAX_NOTIFY_B4, YadaViews.AJAX_NOTIFY_B5));
}
return defaultNotifyModalView;
}
/**
* Get the width/height of an image, for desktop, mobile and pdf
* @param relativeKey the key like "/product/gallery", relative to "config/dimension"
* @return { desktopDimension, mobileDimension, pdfDimension }, the cell is null when not configured
*/
protected YadaIntDimension[] getImageDimensions(String relativeKey) {
YadaIntDimension[] result = new YadaIntDimension[3];
result[0] = splitDimension(relativeKey, "/desktop");
result[1] = splitDimension(relativeKey, "/mobile");
result[2] = splitDimension(relativeKey, "/pdf");
return result;
}
/**
* Converts a value like <desktop>1920,973</desktop> to a YadaIntDimension
* @param relativeKey relativeKey the key like "/product/gallery", relative to "config/dimension"
* @param type "/desktop" or "/mobile" etc as found in the configuration xml
* @return YadaIntDimension or null
*/
private YadaIntDimension splitDimension(String relativeKey, String type) {
String widthHeight = configuration.getString("config/dimension" + relativeKey + type, null);
if (widthHeight!=null) {
String[] parts = widthHeight.split(",");
int width = Integer.parseInt(parts[0]);
int height = Integer.parseInt(parts[1]);
return new YadaIntDimension(width, height);
}
return null;
}
/**
* Returns the image extension (without dot) to use when uploading user images. Defaults to "jpg".
* @return
*/
public String getTargetImageExtension() {
if (targetImageExtension==null) {
targetImageExtension = configuration.getString("config/dimension/@targetImageExtension", "jpg");
targetImageExtension = StringUtils.removeStart(targetImageExtension, "."); // Remove dot if any
}
return targetImageExtension;
}
/**
* Check if the image extension has to be preserved when converting.
* The value is taken from <dimension targetImageExtension="jpg" preserveImageExtension="gif,webp">
* @param extensionNoDot
* @return
*/
public boolean isPreserveImageExtension(String extensionNoDot) {
if (preserveImageExtensions==null) {
preserveImageExtensions = configuration.getString("config/dimension/@preserveImageExtensions", "");
preserveImageExtensions = StringUtils.remove(preserveImageExtensions, '.'); // Remove dot if any
// Add commas for easy search
preserveImageExtensions = ','+preserveImageExtensions.toLowerCase()+',';
}
return preserveImageExtensions.contains(','+extensionNoDot.toLowerCase()+',');
}
/**
* Tells if the YadaFileManager has to delete uploaded files when attaching them, or to keep them in the uploads folder
* for later use. true by default.
* @return
*/
public boolean isFileManagerDeletingUploads() {
return configuration.getBoolean("config/yadaFileManager/deleteUploads", true);
}
/**
* Looks in the configuration for a list of ids, then fetches from message.properties the localized text corresponding to the ids.
* The id is appended to the messageBaseKey parameter after a dot.
* The result is a sorted set of id-text, sorted on the text.
*
* Example:
* In the config we have
*
* <countries>
* <countryId>1</countryId>
* <countryId>2</countryId>
* <countryId>3</countryId>
* <countryId>4</countryId>
* </countries>
*
* In message.properties we have
*
* customer.country.1 = England
* customer.country.2 = France
* customer.country.3 = USA
* customer.country.4 = Albania
*
* The call to this method will be:
*
* config.getLocalSet("config/countries/countryId", "customer.country", locale, messageSource);
*
* The resulting set will have the countries in the following order: Albania, England, France, USA.
* Results are cached forever.
*
* @param configPath item in the configuration file that holds the id. There should be more than one such item in the configuration.
* Example: config/countries/countryId
* @param messageBaseKey the prefix of the message.properties key to which the id should be appended in order to retrieve the localized text.
* Example: customer.country
* @param locale
* @param messageSource
* @return
* @see YadaLocalEnum
*/
@Deprecated // Never tested and never used. You are probably better off with a YadaLocalEnum
public SortedSet> getLocalSet(String configPath, String messageBaseKey, Locale locale, MessageSource messageSource) {
String cacheKey = configPath + messageBaseKey + locale.toString();
SortedSet> result = localSetCache.get(cacheKey);
if (result!=null) {
return result;
}
result = new TreeSet<>(new Comparator>() {
@Override
public int compare(Entry element1, Entry element2) {
return element1.getValue().compareTo(element2.getValue());
}
});
List ids = configuration.getList(Integer.class, configPath);
for (Integer id : ids) {
String key = messageBaseKey + "." + id;
String localizedText = messageSource.getMessage(key, null, key, locale);
Entry entry = new AbstractMap.SimpleImmutableEntry<>(id, localizedText);
result.add(entry);
}
localSetCache.put(cacheKey, result);
return result;
}
public String getUploadsDirname() {
if (uploadsDir==null) {
uploadsDir = configuration.getString("config/paths/uploadsDir", "uploads");
}
return uploadsDir;
}
// This has been removed because uploaded files should not be public.
// They should be moved to a public folder in order to show them via apache.
// /**
// * Returns the url for the uploads folder
// * @return
// */
// public String getUploadsUrl() {
// return this.getContentUrl() + "/" + getUploadsDirname();
// }
/**
* Folder where files are uploaded before processing. Should not be a public folder.
* @return
*/
public File getUploadsFolder() {
if (uploadsFolder==null) {
uploadsFolder = new File(getBasePathString(), getUploadsDirname());
if (!uploadsFolder.exists()) {
uploadsFolder.mkdirs();
}
}
return uploadsFolder;
}
/**
* Returns true if YadaEmaiService should throw an exception instead of returning false when it receives an exception on send
* @return
*/
public boolean isEmailThrowExceptions() {
return configuration.getBoolean("config/email/@throwExceptions", false);
}
/**
* @return the url to redirect to after sending the password reset email
*/
public String getPasswordResetSent(Locale locale) {
String link = configuration.getString("config/security/passwordReset/passwordResetSent", "/");
return fixLink(link, locale);
}
/**
* @return the link to use for the registration confirmation, e.g. "/my/registrationConfirmation" or "/en/my/registrationConfirmation"
*/
public String getRegistrationConfirmationLink(Locale locale) {
String link = configuration.getString("config/security/registration/confirmationLink", "/registrationConfirmation");
return fixLink(link, locale);
}
private String fixLink(String link, Locale locale) {
if (!link.endsWith("/")) {
link = link + "/";
}
if (!link.startsWith("/")) {
link = "/" + link;
}
if (isLocalePathVariableEnabled()) {
// Add the locale
link = "/" + locale.getLanguage() + link;
}
return link;
}
/**
* Checks if an email address has been blacklisted in the configuration
* @param email
* @return true for a blacklisted email address
*/
public boolean emailBlacklisted(String email) {
if (email==null) {
log.warn("Blacklisting null email address");
return true;
}
String[] patternStrings = configuration.getStringArray("config/email/blacklistPattern");
for (String patternString : patternStrings) {
// (?i) is for case-insensitive match
if (email.matches("(?i)"+patternString)) {
log.warn("Email '{}' blacklisted by '{}'", email, patternString);
return true;
}
}
return false;
}
/**
* True if during startup YadaAppConfig should run the FlyWay migrate operation
* @return
*/
public boolean useDatabaseMigrationAtStartup() {
return configuration.getBoolean("config/database/databaseMigrationAtStartup", false);
}
/**
* Name of the flyway schema history table. Uses the default name of "flyway_schema_history" when not configured.
* @return the table name, never null
*/
public String flywayTableName() {
return configuration.getString("config/database/flywayTableName", "flyway_schema_history");
}
/**
* "Out of order" flag in FlyWay
* https://flywaydb.org/documentation/configuration/parameters/outOfOrder
* @return
*/
public boolean useDatabaseMigrationOutOfOrder() {
return configuration.getBoolean("config/database/databaseMigrationAtStartup/@outOfOrder", false);
}
/**
* Returns the configured default locale.
* @return the default locale, or null if no default is set
*/
public Locale getDefaultLocale() {
if (!defaultLocaleChecked) {
defaultLocaleChecked = true;
String localeString = configuration.getString("config/i18n/locale[@default='true']", null);
if (localeString!=null) {
try {
String country = getCountryForLanguage(localeString);
if (country!=null) {
localeString += "_" + country;
}
defaultLocale = LocaleUtils.toLocale(localeString);
} catch (IllegalArgumentException e) {
throw new YadaConfigurationException("Locale {} is invalid", localeString);
}
} else {
log.warn("No default locale has been set with : set a default locale if you don't want empty strings returned for missing localized values in the database");
}
}
return defaultLocale;
}
/**
* True if the filter enables the use of locales in the url, like /en/mypage
* @return
*/
public boolean isLocalePathVariableEnabled() {
if (localePathVariableEnabled==null) {
localePathVariableEnabled = configuration.getBoolean("config/i18n/@localePathVariable", false);
}
return localePathVariableEnabled.booleanValue();
}
// /**
// * Returns the locale to be injected in the request.
// * Used when the locale string only has the language and you want to store the full language_COUNTRY locale in your request.
// * The configuration must be like <locale>es<request>es_ES</request></locale>
// * @param locale
// * @return either the input parameter unaltered or the "request" locale if configured
// */
// public String getLocaleForRequest(String locale) {
// return configuration.getString("config/i18n/locale[text()='" + locale + "']/request", locale);
// }
public String getCountryForLanguage(String language) {
if (languageToCountry==null) {
languageToCountry = new HashMap<>();
List locales = configuration.immutableConfigurationsAt("config/i18n/locale");
for (ImmutableHierarchicalConfiguration localeConfig : locales) {
String languageKey = localeConfig.getString(".");
String countryValue = localeConfig.getString("./@country");
languageToCountry.put(languageKey, countryValue);
}
}
return languageToCountry.get(language);
}
/**
* True if locale paths only have the language component ("en") but you also need the country component ("US") in the request Locale
*/
public boolean isLocaleAddCountry() {
if (localeAddCountry==null) {
localeAddCountry = configuration.containsKey("config/i18n/locale/@country");
}
return localeAddCountry.booleanValue();
}
/**
* Get the set of configured locales in no particular order.
* @deprecated because doesn't consider countries when configured
*/
@Deprecated
public Set getLocaleSet() {
if (localeSet==null) {
getLocaleStrings(); // Init the set
}
return localeSet;
}
/**
* Get a list of iso2 locales that the webapp can handle
* @deprecated because doesn't consider countries when configured
*/
@Deprecated
public List getLocaleStrings() {
if (locales==null) {
locales = Arrays.asList(configuration.getStringArray("config/i18n/locale"));
localeSet = new HashSet();
for (String locale : locales) {
try {
Locale localeObject = LocaleUtils.toLocale(locale);
localeSet.add(localeObject);
} catch (IllegalArgumentException e) {
throw new YadaConfigurationException("Locale {} is invalid", locale);
}
}
}
return locales;
}
/**
* Returns the configured locales as objects, using countries if configured
*/
public List getLocales() {
if (localeObjects==null) {
localeObjects = new ArrayList();
List locales = configuration.immutableConfigurationsAt("config/i18n/locale");
for (ImmutableHierarchicalConfiguration localeConfig : locales) {
String languageKey = localeConfig.getString(".");
String countryValue = localeConfig.getString("./@country", null);
Locale locale = countryValue==null?new Locale(languageKey):new Locale(languageKey, countryValue);
localeObjects.add(locale);
}
localeObjects = Collections.unmodifiableList(localeObjects);
}
return localeObjects;
}
/**
* Returns the page to forward to after an unhandled exception or HTTP error.
* The value is the @RequestMapping value, without language in the path
* @return
*/
public String getErrorPageForward() {
if (errorPageForward==null) {
errorPageForward = configuration.getString("/config/paths/errorPageForward", "/");
}
return errorPageForward;
}
public boolean isBeta() {
if (beta==null) {
beta=configuration.getBoolean("/config/info/beta", false);
}
return beta;
}
public boolean isAlpha() {
if (alpha==null) {
alpha=configuration.getBoolean("/config/info/alpha", false);
}
return alpha;
}
public int getMaxPasswordLength() {
if (maxPwdLen<0) {
maxPwdLen = configuration.getInt("config/security/passwordLength/@max", 16);
}
return maxPwdLen;
}
public int getMinPasswordLength() {
if (minPwdLen<0) {
minPwdLen = configuration.getInt("config/security/passwordLength/@min", 0);
}
return minPwdLen;
}
/**
* Ritorna il path del folder in cui sono memorizzate le immagini temporanee accessibili via web, ad esempio per il preview della newsletter
* @return
*/
public File getTempImageDir() {
if (tempFolder==null) {
tempFolder = new File(getContentPath(), getTempImageRelativePath());
if (!tempFolder.exists()) {
tempFolder.mkdirs();
}
}
return tempFolder;
}
/**
* Ritorna il path del folder in cui sono memorizzate le immagini temporanee accessibili via web, relativamente al folder "contents"
* @return
*/
public String getTempImageRelativePath() {
return "/tmp";
}
/**
* Return the webapp address without a trailing slash. E.g. http://www.mysite.com/app or http://www.mysite.com
* The address is computed from the request when not null, else it is read from the configuration.
* @return
*/
public String getWebappAddress(HttpServletRequest request) {
if (webappAddress==null) {
if (request!=null) {
StringBuilder address = new StringBuilder(getServerAddress(request)); // http://www.example.com
address.append(request.getContextPath()); // http://www.example.com/appname
// Adding the language is a bug because the value is cached
// if (isLocalePathVariableEnabled() && locale!=null) {
// address.append("/").append(locale.getLanguage()); // http://www.example.com/en
// }
webappAddress = address.toString();
} else {
webappAddress = getWebappAddress();
}
}
return webappAddress;
}
/**
* Return the webapp address without a trailing slash. E.g. http://www.mysite.com/app or http://www.mysite.com
* @return
*/
public String getWebappAddress() {
if (webappAddress==null) {
String contextPath = StringUtils.removeEnd(configuration.getString("config/paths/contextPath", ""), "/");
webappAddress = getServerAddress() + "/" + contextPath;
webappAddress = StringUtils.removeEnd(webappAddress, "/");
}
return webappAddress;
}
/**
* Return the server address without a trailing slash. E.g. http://col.letturedametropolitana.it
* Warning: this version does not work properly behind an ajp connector
* @return
* @deprecated use {@link #getServerAddress()} instead
*/
@Deprecated
public String getServerAddress(HttpServletRequest request) {
if (serverAddress==null) {
StringBuilder address = new StringBuilder();
address.append(request.getScheme()).append("://").append(request.getServerName()); // http://www.example.com
if (request.getServerPort()!=80 && request.getServerPort()!=443) {
address.append(":").append(request.getServerPort()); // http://www.example.com:8080
}
serverAddress = address.toString();
}
return serverAddress;
}
/**
* Return the server address without a trailing slash. E.g. http://col.letturedametropolitana.it
* @return
*/
public String getServerAddress() {
if (serverAddress==null) {
serverAddress = StringUtils.removeEnd(configuration.getString("config/paths/serverAddress", "serverAddressUnset"), "/");
}
return serverAddress;
}
public String getEmailLogoImage() {
if (logoImage==null) {
logoImage = configuration.getString("config/email/logoImage", ""); // e.g. /res/img/logo-small.jpg
}
return logoImage;
}
/**
* Returns ".min" if this is not a development environment. Use like