blob: d4e1d37eebea35ea1d0659c1aa36f4fea01e691d [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.guacamole.extension;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.ServletContext;
import org.codehaus.jackson.JsonNode;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.node.JsonNodeFactory;
import org.codehaus.jackson.node.ObjectNode;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.environment.Environment;
import org.apache.guacamole.properties.StringSetProperty;
import org.apache.guacamole.resource.ByteArrayResource;
import org.apache.guacamole.resource.Resource;
import org.apache.guacamole.resource.WebApplicationResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Service which provides access to all built-in languages as resources, and
* allows other resources to be added or overlaid against existing resources.
*/
public class LanguageResourceService {
/**
* Logger for this class.
*/
private final Logger logger = LoggerFactory.getLogger(LanguageResourceService.class);
/**
* The path to the translation folder within the webapp.
*/
private static final String TRANSLATION_PATH = "/translations";
/**
* The JSON property for the human readable display name.
*/
private static final String LANGUAGE_DISPLAY_NAME_KEY = "NAME";
/**
* The Jackson parser for parsing the language JSON files.
*/
private static final ObjectMapper mapper = new ObjectMapper();
/**
* The regular expression to use for parsing the language key from the
* filename.
*/
private static final Pattern LANGUAGE_KEY_PATTERN = Pattern.compile(".*/([a-z]+(_[A-Z]+)?)\\.json");
/**
* Comma-separated list of all allowed languages, where each language is
* represented by a language key, such as "en" or "en_US". If specified,
* only languages within this list will be listed as available by the REST
* service.
*/
public final StringSetProperty ALLOWED_LANGUAGES = new StringSetProperty() {
@Override
public String getName() { return "allowed-languages"; }
};
/**
* The set of all language keys which are explicitly listed as allowed
* within guacamole.properties, or null if all defined languages should be
* allowed.
*/
private final Set<String> allowedLanguages;
/**
* Map of all language resources by language key. Language keys are
* language and country code pairs, separated by an underscore, like
* "en_US". The country code and underscore SHOULD be omitted in the case
* that only one dialect of that language is defined, or in the case of the
* most universal or well-supported of all supported dialects of that
* language.
*/
private final Map<String, Resource> resources = new HashMap<String, Resource>();
/**
* Creates a new service for tracking and parsing available translations
* which reads its configuration from the given environment.
*
* @param environment
* The environment from which the configuration properties of this
* service should be read.
*/
public LanguageResourceService(Environment environment) {
Set<String> parsedAllowedLanguages;
// Parse list of available languages from properties
try {
parsedAllowedLanguages = environment.getProperty(ALLOWED_LANGUAGES);
logger.debug("Available languages will be restricted to: {}", parsedAllowedLanguages);
}
// Warn of failure to parse
catch (GuacamoleException e) {
parsedAllowedLanguages = null;
logger.error("Unable to parse list of allowed languages: {}", e.getMessage());
logger.debug("Error parsing list of allowed languages.", e);
}
this.allowedLanguages = parsedAllowedLanguages;
}
/**
* Derives a language key from the filename within the given path, if
* possible. If the filename is not a valid language key, null is returned.
*
* @param path
* The path containing the filename to derive the language key from.
*
* @return
* The derived language key, or null if the filename is not a valid
* language key.
*/
public String getLanguageKey(String path) {
// Parse language key from filename
Matcher languageKeyMatcher = LANGUAGE_KEY_PATTERN.matcher(path);
if (!languageKeyMatcher.matches())
return null;
// Return parsed key
return languageKeyMatcher.group(1);
}
/**
* Merges the given JSON objects. Any leaf node in overlay will overwrite
* the corresponding path in original.
*
* @param original
* The original JSON object to which changes should be applied.
*
* @param overlay
* The JSON object containing changes that should be applied.
*
* @return
* The newly constructed JSON object that is the result of merging
* original and overlay.
*/
private JsonNode mergeTranslations(JsonNode original, JsonNode overlay) {
// If we are at a leaf node, the result of merging is simply the overlay
if (!overlay.isObject() || original == null)
return overlay;
// Create mutable copy of original
ObjectNode newNode = JsonNodeFactory.instance.objectNode();
Iterator<String> fieldNames = original.getFieldNames();
while (fieldNames.hasNext()) {
String fieldName = fieldNames.next();
newNode.put(fieldName, original.get(fieldName));
}
// Merge each field
fieldNames = overlay.getFieldNames();
while (fieldNames.hasNext()) {
String fieldName = fieldNames.next();
newNode.put(fieldName, mergeTranslations(original.get(fieldName), overlay.get(fieldName)));
}
return newNode;
}
/**
* Parses the given language resource, returning the resulting JsonNode.
* If the resource cannot be read because it does not exist, null is
* returned.
*
* @param resource
* The language resource to parse. Language resources must have the
* mimetype "application/json".
*
* @return
* A JsonNode representing the root of the parsed JSON tree, or null if
* the given resource does not exist.
*
* @throws IOException
* If an error occurs while parsing the resource as JSON.
*/
private JsonNode parseLanguageResource(Resource resource) throws IOException {
// Get resource stream
InputStream stream = resource.asStream();
if (stream == null)
return null;
// Parse JSON tree
try {
JsonNode tree = mapper.readTree(stream);
return tree;
}
// Ensure stream is always closed
finally {
stream.close();
}
}
/**
* Returns whether a language having the given key should be allowed to be
* loaded. If language availability restrictions are imposed through
* guacamole.properties, this may return false in some cases. By default,
* this function will always return true. Note that just because a language
* key is allowed to be loaded does not imply that the language key is
* valid.
*
* @param languageKey
* The language key of the language to test.
*
* @return
* true if the given language key should be allowed to be loaded, false
* otherwise.
*/
private boolean isLanguageAllowed(String languageKey) {
// If no list is provided, all languages are implicitly available
if (allowedLanguages == null)
return true;
return allowedLanguages.contains(languageKey);
}
/**
* Adds or overlays the given language resource, which need not exist in
* the ServletContext. If a language resource is already defined for the
* given language key, the strings from the given resource will be overlaid
* on top of the existing strings, augmenting or overriding the available
* strings for that language.
*
* @param key
* The language key of the resource being added. Language keys are
* pairs consisting of a language code followed by an underscore and
* country code, such as "en_US".
*
* @param resource
* The language resource to add. This resource must have the mimetype
* "application/json".
*/
public void addLanguageResource(String key, Resource resource) {
// Skip loading of language if not allowed
if (!isLanguageAllowed(key)) {
logger.debug("OMITTING language: \"{}\"", key);
return;
}
// Merge language resources if already defined
Resource existing = resources.get(key);
if (existing != null) {
try {
// Read the original language resource
JsonNode existingTree = parseLanguageResource(existing);
if (existingTree == null) {
logger.warn("Base language resource \"{}\" does not exist.", key);
return;
}
// Read new language resource
JsonNode resourceTree = parseLanguageResource(resource);
if (resourceTree == null) {
logger.warn("Overlay language resource \"{}\" does not exist.", key);
return;
}
// Merge the language resources
JsonNode mergedTree = mergeTranslations(existingTree, resourceTree);
resources.put(key, new ByteArrayResource("application/json", mapper.writeValueAsBytes(mergedTree)));
logger.debug("Merged strings with existing language: \"{}\"", key);
}
catch (IOException e) {
logger.error("Unable to merge language resource \"{}\": {}", key, e.getMessage());
logger.debug("Error merging language resource.", e);
}
}
// Otherwise, add new language resource
else {
resources.put(key, resource);
logger.debug("Added language: \"{}\"", key);
}
}
/**
* Adds or overlays all languages defined within the /translations
* directory of the given ServletContext. If no such language files exist,
* nothing is done. If a language is already defined, the strings from the
* will be overlaid on top of the existing strings, augmenting or
* overriding the available strings for that language. The language key
* for each language file is derived from the filename.
*
* @param context
* The ServletContext from which language files should be loaded.
*/
public void addLanguageResources(ServletContext context) {
// Get the paths of all the translation files
Set<?> resourcePaths = context.getResourcePaths(TRANSLATION_PATH);
// If no translation files found, nothing to add
if (resourcePaths == null)
return;
// Iterate through all the found language files and add them to the map
for (Object resourcePathObject : resourcePaths) {
// Each resource path is guaranteed to be a string
String resourcePath = (String) resourcePathObject;
// Parse language key from path
String languageKey = getLanguageKey(resourcePath);
if (languageKey == null) {
logger.warn("Invalid language file name: \"{}\"", resourcePath);
continue;
}
// Add/overlay new resource
addLanguageResource(
languageKey,
new WebApplicationResource(context, "application/json", resourcePath)
);
}
}
/**
* Returns a set of all unique language keys currently associated with
* language resources stored in this service. The returned set cannot be
* modified.
*
* @return
* A set of all unique language keys currently associated with this
* service.
*/
public Set<String> getLanguageKeys() {
return Collections.unmodifiableSet(resources.keySet());
}
/**
* Returns a map of all languages currently associated with this service,
* where the key of each map entry is the language key. The returned map
* cannot be modified.
*
* @return
* A map of all languages currently associated with this service.
*/
public Map<String, Resource> getLanguageResources() {
return Collections.unmodifiableMap(resources);
}
/**
* Returns a mapping of all language keys to their corresponding human-
* readable language names. If an error occurs while parsing a language
* resource, its key/name pair will simply be omitted. The returned map
* cannot be modified.
*
* @return
* A map of all language keys and their corresponding human-readable
* names.
*/
public Map<String, String> getLanguageNames() {
Map<String, String> languageNames = new HashMap<String, String>();
// For each language key/resource pair
for (Map.Entry<String, Resource> entry : resources.entrySet()) {
// Get language key and resource
String languageKey = entry.getKey();
Resource resource = entry.getValue();
// Get stream for resource
InputStream resourceStream = resource.asStream();
if (resourceStream == null) {
logger.warn("Expected language resource does not exist: \"{}\".", languageKey);
continue;
}
// Get name node of language
try {
JsonNode tree = mapper.readTree(resourceStream);
JsonNode nameNode = tree.get(LANGUAGE_DISPLAY_NAME_KEY);
// Attempt to read language name from node
String languageName;
if (nameNode == null || (languageName = nameNode.getTextValue()) == null) {
logger.warn("Root-level \"" + LANGUAGE_DISPLAY_NAME_KEY + "\" string missing or invalid in language \"{}\"", languageKey);
languageName = languageKey;
}
// Add language key/name pair to map
languageNames.put(languageKey, languageName);
}
// Continue with next language if unable to read
catch (IOException e) {
logger.warn("Unable to read language resource \"{}\".", languageKey);
logger.debug("Error reading language resource.", e);
}
}
return Collections.unmodifiableMap(languageNames);
}
}