/* * Copyright (C) 2004-2008 Jive Software. All rights reserved. * * Licensed 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.jivesoftware.util; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.io.StringReader; import java.io.Writer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.StringTokenizer; import org.apache.commons.lang.StringEscapeUtils; import org.dom4j.Attribute; import org.dom4j.CDATA; import org.dom4j.Document; import org.dom4j.Element; import org.dom4j.Node; import org.dom4j.io.OutputFormat; import org.dom4j.io.SAXReader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Provides the the ability to use simple XML property files. Each property is * in the form X.Y.Z, which would map to an XML snippet of: * <pre> * <X> * <Y> * <Z>someValue</Z> * </Y> * </X> * </pre> * The XML file is passed in to the constructor and must be readable and * writable. Setting property values will automatically persist those value * to disk. The file encoding used is UTF-8. * * @author Derek DeMoro * @author Iain Shigeoka */ public class XMLProperties { private static final Logger Log = LoggerFactory.getLogger(XMLProperties.class); private static final String ENCRYPTED_ATTRIBUTE = "encrypted"; private Path file; private Document document; /** * Parsing the XML file every time we need a property is slow. Therefore, * we use a Map to cache property values that are accessed more than once. */ private Map<String, String> propertyCache = new HashMap<>(); /** * Creates a new empty XMLPropertiesTest object. * * @throws IOException if an error occurs loading the properties. */ public XMLProperties() throws IOException { buildDoc(new StringReader("<root />")); } /** * Creates a new XMLPropertiesTest object. * * @param fileName the full path the file that properties should be read from * and written to. * @throws IOException if an error occurs loading the properties. */ public XMLProperties(String fileName) throws IOException { this(Paths.get(fileName)); } /** * Loads XML properties from a stream. * * @param in the input stream of XML. * @throws IOException if an exception occurs when reading the stream. */ public XMLProperties(InputStream in) throws IOException { try (Reader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { buildDoc(reader); } } /** * Creates a new XMLPropertiesTest object. * * @param file the file that properties should be read from and written to. * @throws IOException if an error occurs loading the properties. */ @Deprecated public XMLProperties(File file) throws IOException { this(file.toPath()); } /** * Creates a new XMLPropertiesTest object. * * @param file the file that properties should be read from and written to. * @throws IOException if an error occurs loading the properties. */ public XMLProperties(Path file) throws IOException { this.file = file; if (Files.notExists(file)) { // Attempt to recover from this error case by seeing if the // tmp file exists. It's possible that the rename of the // tmp file failed the last time Jive was running, // but that it exists now. Path tempFile; tempFile = file.getParent().resolve(file.getFileName() + ".tmp"); if (Files.exists(tempFile)) { Log.error("WARNING: " + file.getFileName() + " was not found, but temp file from " + "previous write operation was. Attempting automatic recovery." + " Please check file for data consistency."); Files.move(tempFile, file, StandardCopyOption.REPLACE_EXISTING); } // There isn't a possible way to recover from the file not // being there, so throw an error. else { throw new NoSuchFileException("XML properties file does not exist: " + file.getFileName()); } } // Check read and write privs. if (!Files.isReadable(file)) { throw new IOException("XML properties file must be readable: " + file.getFileName()); } if (!Files.isWritable(file)) { throw new IOException("XML properties file must be writable: " + file.getFileName()); } try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { buildDoc(reader); } } /** * Returns the value of the specified property. * * @param name the name of the property to get. * @return the value of the specified property. */ public synchronized String getProperty(String name) { return getProperty(name, true); } /** * Returns the value of the specified property. * * @param name the name of the property to get. * @param ignoreEmpty Ignore empty property values (return null) * @return the value of the specified property. */ public synchronized String getProperty(String name, boolean ignoreEmpty) { String value = propertyCache.get(name); if (value != null) { return value; } String[] propName = parsePropertyName(name); // Search for this property by traversing down the XML hierarchy. Element element = document.getRootElement(); for (String aPropName : propName) { element = element.element(aPropName); if (element == null) { // This node doesn't match this part of the property name which // indicates this property doesn't exist so return null. return null; } } // At this point, we found a matching property, so return its value. // Empty strings are returned as null. value = element.getTextTrim(); if (ignoreEmpty && "".equals(value)) { return null; } else { // check to see if the property is marked as encrypted if (JiveGlobals.isPropertyEncrypted(name)) { Attribute encrypted = element.attribute(ENCRYPTED_ATTRIBUTE); if (encrypted != null) { value = JiveGlobals.getPropertyEncryptor().decrypt(value); } else { // rewrite property as an encrypted value Log.info("Rewriting XML property " + name + " as an encrypted value"); setProperty(name, value); } } // Add to cache so that getting property next time is fast. propertyCache.put(name, value); return value; } } /** * Return all values who's path matches the given property * name as a String array, or an empty array if the if there * are no children. This allows you to retrieve several values * with the same property name. For example, consider the * XML file entry: * <pre> * <foo> * <bar> * <prop>some value</prop> * <prop>other value</prop> * <prop>last value</prop> * </bar> * </foo> * </pre> * If you call getProperties("foo.bar.prop") will return a string array containing * {"some value", "other value", "last value"}. * * @param name the name of the property to retrieve * @return all child property values for the given node name. */ public List<String> getProperties(String name, boolean asList) { List<String> result = new ArrayList<>(); String[] propName = parsePropertyName(name); // Search for this property by traversing down the XML hierarchy, // stopping one short. Element element = document.getRootElement(); for (int i = 0; i < propName.length - 1; i++) { element = element.element(propName[i]); if (element == null) { // This node doesn't match this part of the property name which // indicates this property doesn't exist so return empty array. return result; } } // We found matching property, return names of children. Iterator<Element> iter = element.elementIterator(propName[propName.length - 1]); Element prop; String value; boolean updateEncryption = false; while (iter.hasNext()) { prop = iter.next(); // Empty strings are skipped. value = prop.getTextTrim(); if (!"".equals(value)) { // check to see if the property is marked as encrypted if (JiveGlobals.isPropertyEncrypted(name)) { Attribute encrypted = prop.attribute(ENCRYPTED_ATTRIBUTE); if (encrypted != null) { value = JiveGlobals.getPropertyEncryptor().decrypt(value); } else { // rewrite property as an encrypted value prop.addAttribute(ENCRYPTED_ATTRIBUTE, "true"); updateEncryption = true; } } result.add(value); } } if (updateEncryption) { Log.info("Rewriting values for XML property " + name + " using encryption"); saveProperties(); } return result; } /** * Return all values who's path matches the given property * name as a String array, or an empty array if the if there * are no children. This allows you to retrieve several values * with the same property name. For example, consider the * XML file entry: * <pre> * <foo> * <bar> * <prop>some value</prop> * <prop>other value</prop> * <prop>last value</prop> * </bar> * </foo> * </pre> * If you call getProperties("foo.bar.prop") will return a string array containing * {"some value", "other value", "last value"}. * * @deprecated Retained for backward compatibility. Prefer getProperties(String, boolean) * @param name the name of the property to retrieve * @return all child property values for the given node name. */ public String[] getProperties(String name) { return (String[]) getProperties(name, false).toArray(); } /** * Return all values who's path matches the given property * name as a String array, or an empty array if the if there * are no children. This allows you to retrieve several values * with the same property name. For example, consider the * XML file entry: * <pre> * <foo> * <bar> * <prop>some value</prop> * <prop>other value</prop> * <prop>last value</prop> * </bar> * </foo> * </pre> * If you call getProperties("foo.bar.prop") will return a string array containing * {"some value", "other value", "last value"}. * * @param name the name of the property to retrieve * @return all child property values for the given node name. */ public Iterator getChildProperties(String name) { String[] propName = parsePropertyName(name); // Search for this property by traversing down the XML hierarchy, // stopping one short. Element element = document.getRootElement(); for (int i = 0; i < propName.length - 1; i++) { element = element.element(propName[i]); if (element == null) { // This node doesn't match this part of the property name which // indicates this property doesn't exist so return empty array. return Collections.EMPTY_LIST.iterator(); } } // We found matching property, return values of the children. Iterator<Element> iter = element.elementIterator(propName[propName.length - 1]); ArrayList<String> props = new ArrayList<>(); Element prop; String value; while (iter.hasNext()) { prop = iter.next(); value = prop.getText(); // check to see if the property is marked as encrypted if (JiveGlobals.isPropertyEncrypted(name) && Boolean.parseBoolean(prop.attribute(ENCRYPTED_ATTRIBUTE).getText())) { value = JiveGlobals.getPropertyEncryptor().decrypt(value); } props.add(value); } return props.iterator(); } /** * Returns the value of the attribute of the given property name or <tt>null</tt> * if it doesn't exist. * * @param name the property name to lookup - ie, "foo.bar" * @param attribute the name of the attribute, ie "id" * @return the value of the attribute of the given property or <tt>null</tt> if * it doesn't exist. */ public String getAttribute(String name, String attribute) { if (name == null || attribute == null) { return null; } String[] propName = parsePropertyName(name); // Search for this property by traversing down the XML hierarchy. Element element = document.getRootElement(); for (String child : propName) { element = element.element(child); if (element == null) { // This node doesn't match this part of the property name which // indicates this property doesn't exist so return empty array. break; } } if (element != null) { // Get its attribute values return element.attributeValue(attribute); } return null; } /** * Removes the given attribute from the XML document. * * @param name the property name to lookup - ie, "foo.bar" * @param attribute the name of the attribute, ie "id" * @return the value of the attribute of the given property or <tt>null</tt> if * it did not exist. */ public String removeAttribute(String name, String attribute) { if (name == null || attribute == null) { return null; } String[] propName = parsePropertyName(name); // Search for this property by traversing down the XML hierarchy. Element element = document.getRootElement(); for (String child : propName) { element = element.element(child); if (element == null) { // This node doesn't match this part of the property name which // indicates this property doesn't exist so return empty array. break; } } String result = null; if (element != null) { // Get the attribute value and then remove the attribute Attribute attr = element.attribute(attribute); result = attr.getValue(); element.remove(attr); } return result; } /** * Sets a property to an array of values. Multiple values matching the same property * is mapped to an XML file as multiple elements containing each value. * For example, using the name "foo.bar.prop", and the value string array containing * {"some value", "other value", "last value"} would produce the following XML: * <pre> * <foo> * <bar> * <prop>some value</prop> * <prop>other value</prop> * <prop>last value</prop> * </bar> * </foo> * </pre> * * @param name the name of the property. * @param values the values for the property (can be empty but not null). */ public void setProperties(String name, List<String> values) { String[] propName = parsePropertyName(name); // Search for this property by traversing down the XML hierarchy, // stopping one short. Element element = document.getRootElement(); for (int i = 0; i < propName.length - 1; i++) { // If we don't find this part of the property in the XML hierarchy // we add it as a new node if (element.element(propName[i]) == null) { element.addElement(propName[i]); } element = element.element(propName[i]); } String childName = propName[propName.length - 1]; // We found matching property, clear all children. List<Element> toRemove = new ArrayList<>(); Iterator<Element> iter = element.elementIterator(childName); while (iter.hasNext()) { toRemove.add(iter.next()); } for (iter = toRemove.iterator(); iter.hasNext();) { element.remove(iter.next()); } // Add the new children. for (String value : values) { Element childElement = element.addElement(childName); if (value.startsWith("<![CDATA[")) { Iterator<Node> it = childElement.nodeIterator(); while (it.hasNext()) { Node node = it.next(); if (node instanceof CDATA) { childElement.remove(node); break; } } childElement.addCDATA(value.substring(9, value.length()-3)); } else { String propValue = StringEscapeUtils.escapeXml(value); // check to see if the property is marked as encrypted if (JiveGlobals.isPropertyEncrypted(name)) { propValue = JiveGlobals.getPropertyEncryptor().encrypt(value); childElement.addAttribute(ENCRYPTED_ATTRIBUTE, "true"); } childElement.setText(propValue); } } saveProperties(); // Generate event. Map<String, Object> params = new HashMap<>(); params.put("value", values); PropertyEventDispatcher.dispatchEvent(name, PropertyEventDispatcher.EventType.xml_property_set, params); } /** * Adds the given value to the list of values represented by the property name. * The property is created if it did not already exist. * * @param propertyName The name of the property list to change * @param value The value to be added to the list * @return True if the value was added to the list; false if the value was already present */ public boolean addToList(String propertyName, String value) { List<String> properties = getProperties(propertyName, true); boolean propertyWasAdded = properties.add(value); if (propertyWasAdded) { setProperties(propertyName, properties); } return propertyWasAdded; } /** * Removes the given value from the list of values represented by the property name. * The property is deleted if it no longer contains any values. * * @param propertyName The name of the property list to change * @param value The value to be removed from the list * @return True if the value was removed from the list; false if the value was not found */ public boolean removeFromList(String propertyName, String value) { List<String> properties = getProperties(propertyName, true); boolean propertyWasRemoved = properties.remove(value); if (propertyWasRemoved) { setProperties(propertyName, properties); } return propertyWasRemoved; } /** * Returns a list of names for all properties found in the XML file. * * @return Names for all properties in the file */ public List<String> getAllPropertyNames() { List<String> result = new ArrayList<>(); for (String propertyName : getChildPropertyNamesFor(document.getRootElement(), "")) { if (getProperty(propertyName) != null) { result.add(propertyName); } } return result; } private List<String> getChildPropertyNamesFor(Element parent, String parentName) { List<String> result = new ArrayList<>(); for (Element child : (Collection<Element>) parent.elements()) { String childName = new StringBuilder(parentName) .append(parentName.isEmpty() ? "" : ".") .append(child.getName()) .toString(); if (!result.contains(childName)) { result.add(childName); result.addAll(getChildPropertyNamesFor(child, childName)); } } return result; } /** * Return all children property names of a parent property as a String array, * or an empty array if the if there are no children. For example, given * the properties <tt>X.Y.A</tt>, <tt>X.Y.B</tt>, and <tt>X.Y.C</tt>, then * the child properties of <tt>X.Y</tt> are <tt>A</tt>, <tt>B</tt>, and * <tt>C</tt>. * * @param parent the name of the parent property. * @return all child property values for the given parent. */ public String[] getChildrenProperties(String parent) { String[] propName = parsePropertyName(parent); // Search for this property by traversing down the XML hierarchy. Element element = document.getRootElement(); for (String aPropName : propName) { element = element.element(aPropName); if (element == null) { // This node doesn't match this part of the property name which // indicates this property doesn't exist so return empty array. return new String[]{}; } } // We found matching property, return names of children. List children = element.elements(); int childCount = children.size(); String[] childrenNames = new String[childCount]; for (int i = 0; i < childCount; i++) { childrenNames[i] = ((Element)children.get(i)).getName(); } return childrenNames; } /** * Sets the value of the specified property. If the property doesn't * currently exist, it will be automatically created. * * @param name the name of the property to set. * @param value the new value for the property. */ public synchronized void setProperty(String name, String value) { if(!StringEscapeUtils.escapeXml(name).equals(name)) { throw new IllegalArgumentException("Property name cannot contain XML entities."); } if (name == null) { return; } if (value == null) { value = ""; } // Set cache correctly with prop name and value. propertyCache.put(name, value); String[] propName = parsePropertyName(name); // Search for this property by traversing down the XML hierarchy. Element element = document.getRootElement(); for (String aPropName : propName) { // If we don't find this part of the property in the XML hierarchy // we add it as a new node if (element.element(aPropName) == null) { element.addElement(aPropName); } element = element.element(aPropName); } // Set the value of the property in this node. if (value.startsWith("<![CDATA[")) { Iterator it = element.nodeIterator(); while (it.hasNext()) { Node node = (Node) it.next(); if (node instanceof CDATA) { element.remove(node); break; } } element.addCDATA(value.substring(9, value.length()-3)); } else { String propValue = StringEscapeUtils.escapeXml(value); // check to see if the property is marked as encrypted if (JiveGlobals.isPropertyEncrypted(name)) { propValue = JiveGlobals.getPropertyEncryptor().encrypt(value); element.addAttribute(ENCRYPTED_ATTRIBUTE, "true"); } element.setText(propValue); } // Write the XML properties to disk saveProperties(); // Generate event. Map<String, Object> params = new HashMap<>(); params.put("value", value); PropertyEventDispatcher.dispatchEvent(name, PropertyEventDispatcher.EventType.xml_property_set, params); } /** * Deletes the specified property. * * @param name the property to delete. */ public synchronized void deleteProperty(String name) { // Remove property from cache. propertyCache.remove(name); String[] propName = parsePropertyName(name); // Search for this property by traversing down the XML hierarchy. Element element = document.getRootElement(); for (int i = 0; i < propName.length - 1; i++) { element = element.element(propName[i]); // Can't find the property so return. if (element == null) { return; } } // Found the correct element to remove, so remove it... element.remove(element.element(propName[propName.length - 1])); if (element.elements().size() == 0) { element.getParent().remove(element); } // .. then write to disk. saveProperties(); JiveGlobals.setPropertyEncrypted(name, false); // Generate event. Map<String, Object> params = Collections.emptyMap(); PropertyEventDispatcher.dispatchEvent(name, PropertyEventDispatcher.EventType.xml_property_deleted, params); } /** * Convenience routine to migrate an XML property into the database * storage method. Will check for the XML property being null before * migrating. * * @param name the name of the property to migrate. */ public void migrateProperty(String name) { if (getProperty(name) != null) { if (JiveGlobals.getProperty(name) == null) { Log.debug("JiveGlobals: Migrating XML property '"+name+"' into database."); JiveGlobals.setProperty(name, getProperty(name)); deleteProperty(name); } else if (JiveGlobals.getProperty(name).equals(getProperty(name))) { Log.debug("JiveGlobals: Deleting duplicate XML property '"+name+"' that is already in database."); deleteProperty(name); } else if (!JiveGlobals.getProperty(name).equals(getProperty(name))) { Log.warn("XML Property '"+name+"' differs from what is stored in the database. Please make property changes in the database instead of the configuration file."); } } } /** * Builds the document XML model up based the given reader of XML data. * @param in the input stream used to build the xml document * @throws java.io.IOException thrown when an error occurs reading the input stream. */ private void buildDoc(Reader in) throws IOException { try { SAXReader xmlReader = new SAXReader(); xmlReader.setEncoding("UTF-8"); document = xmlReader.read(in); } catch (Exception e) { Log.error("Error reading XML properties", e); throw new IOException(e.getMessage()); } } /** * Saves the properties to disk as an XML document. A temporary file is * used during the writing process for maximum safety. */ private synchronized void saveProperties() { if (file == null) { Log.error("Unable to save XML properties; no file specified"); return; } boolean error = false; // Write data out to a temporary file first. Path tempFile = file.getParent().resolve(file.getFileName() + ".tmp"); try (Writer writer = Files.newBufferedWriter(tempFile, StandardCharsets.UTF_8)) { OutputFormat prettyPrinter = OutputFormat.createPrettyPrint(); XMLWriter xmlWriter = new XMLWriter(writer, prettyPrinter); xmlWriter.write(document); } catch (Exception e) { Log.error(e.getMessage(), e); // There were errors so abort replacing the old property file. error = true; } // No errors occurred, so delete the main file. if (!error) { // Delete the old file so we can replace it. try { Files.deleteIfExists(file); } catch (IOException e) { Log.error("Error deleting property file: " + file); return; } // Copy new contents to the file. try { Files.copy(tempFile, file, StandardCopyOption.REPLACE_EXISTING); } catch (Exception e) { Log.error(e.getMessage(), e); // There were errors so abort replacing the old property file. error = true; } // If no errors, delete the temp file. if (!error) { try { Files.deleteIfExists(tempFile); } catch (IOException e) { Log.error("Error deleting temp file: " + tempFile); } } } } /** * Returns an array representation of the given Jive property. Jive * properties are always in the format "prop.name.is.this" which would be * represented as an array of four Strings. * * @param name the name of the Jive property. * @return an array representation of the given Jive property. */ private String[] parsePropertyName(String name) { List<String> propName = new ArrayList<>(5); // Use a StringTokenizer to tokenize the property name. StringTokenizer tokenizer = new StringTokenizer(name, "."); while (tokenizer.hasMoreTokens()) { propName.add(tokenizer.nextToken()); } return propName.toArray(new String[propName.size()]); } public void setProperties(Map<String, String> propertyMap) { for (String propertyName : propertyMap.keySet()) { String propertyValue = propertyMap.get(propertyName); setProperty(propertyName, propertyValue); } } }