package org.ovirt.engine.core.branding; import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.ResourceBundle; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.codehaus.jackson.map.ObjectMapper; import org.codehaus.jackson.node.ObjectNode; import org.ovirt.engine.core.utils.EngineLocalConfig; import org.ovirt.engine.core.utils.servlet.LocaleFilter; /** * This class manages the available branding themes and changeable localized messages. */ public class BrandingManager { /** * The prefix of the keys in the properties. */ public static final String WELCOME = "welcome"; /** * The default branding path. */ private static final String BRANDING_PATH = "branding"; //$NON-NLS-1$ /** * The prefix denoting this is part of the branding. */ private static final String BRAND_PREFIX = "obrand"; //$NON-NLS-1$ /** * The place holder for the userLocale in the welcome page templates. */ private static final String USER_LOCALE_HOLDER = "\\{userLocale\\}"; //$NON-NLS-1$ /** * The prefix used for common messages. */ private static final String COMMON_PREFIX = BRAND_PREFIX + ".common"; //$NON-NLS-1$ /** * The regular expression {@code Pattern} to use to determine if a directory should be used * as a branding directory. The pattern is '.+\.brand' So anything ending in '.brand' will do. */ private static final Pattern DIRECTORY_PATTERN = Pattern.compile(".+\\.brand"); //$NON-NLS-1$ /** * The regular expression {@code Pattern} to use to find the replacement keys. */ private static final Pattern TEMPLATE_PATTERN = Pattern.compile("\\{(\\D[\\w|\\.]*)\\}"); //$NON-NLS-1$ /** * Only load branding themes for the current branding version. This allows for multiple version of a particular * branding theme to exist on the file system without interfering with each other. There is no backwards * compatibility, only ONE version will be valid at a time. */ private static final int CURRENT_BRANDING_VERSION = 2; /** * A list of available {@code BrandingTheme}s. */ private List<BrandingTheme> themes; /** * The root path of the branding themes. */ private final File brandingRootPath; /** * Instance of the holder pattern, instance doesn't get initialized until needed. This removes * the need for synchronization and double locking pattern. */ private static class Holder { /** * Instance of the BrandingManager. */ static final BrandingManager instance; static { File etcDir; try { etcDir = EngineLocalConfig.getInstance().getEtcDir(); } catch (IllegalArgumentException iae) { etcDir = new File(""); // Can't find etcDir, most likely unit tests, pretend there is no branding. } instance = new BrandingManager(etcDir); } } /** * ObjectMapper to translate map into Javascript. */ private final ObjectMapper objectMapper; /** * Constructor that takes a {@code File} object to configure the brandingRootPath. * @param etcDir A {@code File} pointing to the branding root path. */ BrandingManager(final File etcDir) { brandingRootPath = new File(etcDir, BRANDING_PATH); objectMapper = new ObjectMapper(); } /** * Get an instance of the {@code BrandingManager} with the default ETC_DIR. * @return A {@code BrandingManager} */ public static BrandingManager getInstance() { return Holder.instance; } /** * Get a list of available {@code BrandingTheme}s. * @return A {@code List} of {@code BrandingTheme}s. */ public synchronized List<BrandingTheme> getBrandingThemes() { if (themes == null && brandingRootPath.exists() && brandingRootPath.isDirectory() && brandingRootPath.canRead()) { themes = new ArrayList<>(); File[] directories = brandingRootPath.listFiles( file -> file.isDirectory() && DIRECTORY_PATTERN.matcher(file.getName()).matches()); if (directories != null) { Arrays.sort(directories); for (File directory : directories) { BrandingTheme theme = new BrandingTheme(directory.getAbsolutePath(), brandingRootPath, CURRENT_BRANDING_VERSION); if (theme.load()) { themes.add(theme); } } } } return themes != null ? themes : new ArrayList<>(); } /** * Get the message associated with the passed in key. * @param key The key to get the message for. For instance obrand.common.copy_right_notice. * @return The associated message in the default locale. */ public String getMessage(final String key) { return getMessage(key, LocaleFilter.DEFAULT_LOCALE); } /** * Get the message associated with the passed in key. * @param key The key to get the message for. For instance obrand.common.copy_right_notice. * @param locale The locale to use to look up the message. * @return The associated message in the passed in locale. */ public String getMessage(final String key, final Locale locale) { String result = ""; // key needs to start with obrand. if (key != null && key.startsWith(BRAND_PREFIX + ".")) { String[] splitString = key.split("\\."); String prefix = (splitString.length >= 2) ? splitString[1] : ""; if (prefix.length() > 0) { result = getMessageMap(prefix, locale).get(key.substring(key.indexOf(prefix) + prefix.length() + 1)); } } return result; } /** * Returns a Map of String keys and values. * @param prefix The prefix to use for getting the keys. * @param locale The locale to get the messages for. * @return A {@code Map} of keys and values. */ Map<String, String> getMessageMap(final String prefix, final Locale locale) { List<BrandingTheme> messageThemes = getBrandingThemes(); // We need this map to remove potential duplicate strings from the resource bundles. Map<String, String> keyValues = new HashMap<>(); if (messageThemes != null) { for (BrandingTheme theme : messageThemes) { List<ResourceBundle> bundles = theme.getMessagesBundle(locale); for (ResourceBundle bundle: bundles) { getKeyValuesFromResourceBundle(prefix, keyValues, bundle); } } } return keyValues; } /** * Extract values from the passed resource bundle and put it into the passed in Map based on the prefix passed in. * @param prefix The prefix to use. * @param keyValues The {@code Map} to put the values into. * @param messagesBundle The {@code ResourceBundle} to get the values from. */ private void getKeyValuesFromResourceBundle(final String prefix, Map<String, String> keyValues, ResourceBundle messagesBundle) { for (String key : messagesBundle.keySet()) { if (key.startsWith(BRAND_PREFIX + "." + prefix) || key.startsWith(COMMON_PREFIX)) { //$NON-NLS-1$ // We can potentially override existing values here // but this is fine as the themes are sorted in order // And later messages should override earlier ones. keyValues.put(key.replaceFirst(BRAND_PREFIX + "\\." //$NON-NLS-1$ + prefix + "\\.", "") //$NON-NLS-1$ .replaceFirst(COMMON_PREFIX + "\\.", ""), //$NON-NLS-1$ messagesBundle.getString(key)); } } } /** * get a JavaScript associative array string representation of the available messages. Only 'common' messages and * messages that have keys that start with the passed in prefix will be returned. * @param prefix The prefix to use for getting the keys. * @param locale The locale to get the messages for. * @return A string of format {'key':'value',...} */ public String getMessages(final String prefix, final Locale locale) { Map<String, String> keyValues = getMessageMap(prefix, locale); // Turn the map into a string with the format: // {"key":"value",...} return getMessagesFromMap(keyValues); } /** * @param keyValues The map to turn into the string. * @return A string of format {"key":"value",...} */ String getMessagesFromMap(final Map<String, String> keyValues) { ObjectNode node = objectMapper.createObjectNode(); for (Map.Entry<String, String> entry : keyValues.entrySet()) { node.put(entry.getKey(), entry.getValue()); } return node.size() > 0 ? node.toString() : null; } /** * Get the root path of the branding files. * @return A {@code File} containing the root path. */ public File getBrandingRootPath() { return brandingRootPath; } /** * Look up the welcome section of the top branding theme. The message keys are translated in the language of * the passed in {@code Locale} * @param locale The {@code Locale} to use to look up the appropriate messages. * @return An HTML string to be placed in the welcome page. */ public String getWelcomeSections(final Locale locale) { Map<String, String> messageMap = getMessageMap(WELCOME, locale); List<BrandingTheme> brandingThemes = getBrandingThemes(); StringBuilder templateBuilder = new StringBuilder(); for (BrandingTheme theme: brandingThemes) { String template = theme.getWelcomePageSectionTemplate(); String replacedTemplate = template; Matcher keyMatcher = TEMPLATE_PATTERN.matcher(template); while (keyMatcher.find()) { String key = keyMatcher.group(1); // Don't replace {userLocale} here. if (!USER_LOCALE_HOLDER.substring(2, USER_LOCALE_HOLDER.length() - 2).equals(key) && messageMap.get(key) != null) { replacedTemplate = replacedTemplate.replaceAll("\\{" + key + "\\}", //$NON-NLS-1 //$NON-NLS-2$ messageMap.get(key)); } } replacedTemplate = replacedTemplate.replaceAll(USER_LOCALE_HOLDER, locale.toString()); if (theme.shouldReplaceWelcomePageSectionTemplate()) { //Clear the template builder as the theme wants to replace instead of append to the template. templateBuilder = new StringBuilder(); } templateBuilder.append(replacedTemplate); } return templateBuilder.toString(); } /** * <p>Look up the path to some cascading-capable resource. Branding uses CSS to handle cascading styles, * and a style could be partially overridden by a "higher" brand. But HTML has no way to cascade * simple images. So this method implements a similar cascading for other resources, like images * (or any other resource that can be served out of a brand). * </p> * <p> * We first look in the highest-numbered theme for the file. If it exists, its path is * returned. If that theme has no such file, we look in the next-highest theme. And so on. If no * matching files are found, return null. * @param resourceName the name of the resource. * @return resource to serve, or null if no matching files exist */ public CascadingResource getCascadingResource(final String resourceName) { if (resourceName == null) { return null; } List<BrandingTheme> brandingThemes = getBrandingThemes(); // assume these are sorted 00, 01, ... // return the first one we find for (int i = brandingThemes.size() - 1; i >= 0; i--) { CascadingResource cascadingResource = brandingThemes.get(i).getCascadingResource(resourceName); if (cascadingResource != null) { return cascadingResource; } } // couldn't find it in any brand return null; } }