001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (http://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software, please see the
018 * company website: http://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: http://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.setup.xml;
029
030import org.opencms.configuration.CmsConfigurationManager;
031import org.opencms.json.JSONException;
032import org.opencms.json.JSONObject;
033import org.opencms.util.CmsFileUtil;
034import org.opencms.util.CmsStringUtil;
035import org.opencms.xml.CmsXmlEntityResolver;
036import org.opencms.xml.CmsXmlException;
037import org.opencms.xml.CmsXmlUtils;
038
039import java.io.ByteArrayInputStream;
040import java.io.ByteArrayOutputStream;
041import java.io.File;
042import java.io.FileInputStream;
043import java.io.FileOutputStream;
044import java.io.IOException;
045import java.io.InputStream;
046import java.io.StringReader;
047import java.nio.charset.StandardCharsets;
048import java.util.ArrayList;
049import java.util.List;
050import java.util.regex.Matcher;
051import java.util.regex.Pattern;
052
053import javax.xml.parsers.ParserConfigurationException;
054import javax.xml.parsers.SAXParserFactory;
055import javax.xml.transform.OutputKeys;
056import javax.xml.transform.Result;
057import javax.xml.transform.Source;
058import javax.xml.transform.Transformer;
059import javax.xml.transform.TransformerConfigurationException;
060import javax.xml.transform.TransformerException;
061import javax.xml.transform.TransformerFactory;
062import javax.xml.transform.URIResolver;
063import javax.xml.transform.sax.SAXSource;
064import javax.xml.transform.stream.StreamResult;
065import javax.xml.transform.stream.StreamSource;
066
067import org.apache.xml.utils.SystemIDResolver;
068
069import org.dom4j.Document;
070import org.dom4j.Element;
071import org.dom4j.Node;
072import org.xml.sax.EntityResolver;
073import org.xml.sax.InputSource;
074import org.xml.sax.SAXException;
075import org.xml.sax.XMLReader;
076
077/**
078 * Class for updating the XML configuration files using a set of XSLT transforms.
079 *
080 * The XSLT transforms are stored in the directory update/xmlupdate, together with a file transforms.xml
081 * that contains the list of transformation files and the configuration files to which they should be applied to.
082 *
083 */
084public class CmsXmlConfigUpdater {
085
086    /**
087     * Need this so that 'dummy' entity resolver is also used for documents read with the document() function.
088     */
089    public class EntityIgnoringUriResolver implements URIResolver {
090
091        public Source resolve(String href, String base) throws TransformerException {
092
093            try {
094                String uri = SystemIDResolver.getAbsoluteURI(href, base);
095                XMLReader reader = m_parserFactory.newSAXParser().getXMLReader();
096                reader.setEntityResolver(NO_ENTITY_RESOLVER);
097                Source source;
098                source = new SAXSource(reader, new InputSource(uri));
099                return source;
100            } catch (Exception e) {
101                throw new TransformerException(e);
102            }
103        }
104    }
105
106    /**
107     * Single entry from transforms.xml.
108     */
109    private class TransformEntry {
110
111        /** Name of the config file. */
112        private String m_configFile;
113
114        /** Name of the XSLT file. */
115        private String m_xslt;
116
117        /**
118         * Creates a new entry.
119         *
120         * @param configFile the name of the config file
121         * @param xslt the name of the XSLT file
122         */
123        public TransformEntry(String configFile, String xslt) {
124
125            super();
126            m_xslt = xslt;
127            m_configFile = configFile;
128        }
129
130        /**
131         * Gets the name of the config file.
132         *
133         * @return the name of the config file
134         */
135        public String getConfigFile() {
136
137            return m_configFile;
138        }
139
140        /**
141         * Gets the name of the XSLT file.
142         *
143         * @return the name of the XSLT file
144         */
145        public String getXslt() {
146
147            return m_xslt;
148        }
149
150    }
151
152    /**
153     * Default XML for new config files.
154     */
155    public static final String DEFAULT_XML = "<opencms/>";
156
157    /** Entity resolver to skip dtd validation. */
158    private static final EntityResolver NO_ENTITY_RESOLVER = new EntityResolver() {
159
160        /**
161         * @see org.xml.sax.EntityResolver#resolveEntity(java.lang.String, java.lang.String)
162         */
163        public InputSource resolveEntity(String publicId, String systemId) {
164
165            // return new InputSource(new StringReader("<!ELEMENT opencms ANY>"));
166            return new InputSource(new StringReader(""));
167        }
168    };
169
170    /** Directory for the config files. */
171    private File m_configDir;
172
173    /**Flag to indicate if transformation was done.*/
174    private boolean m_isDone = false;
175
176    /** The parser factory. */
177    private SAXParserFactory m_parserFactory = SAXParserFactory.newInstance();
178
179    /** The transformer factory. */
180    private TransformerFactory m_transformerFactory = new org.apache.xalan.processor.TransformerFactoryImpl();
181
182    /** The directory containing the XSLT transforms. */
183    private File m_xsltDir;
184
185    /**
186     * Creates a new instance.
187     *
188     * @param xsltDir the directory containing the XSLT files
189     * @param configDir the configuration directory
190     */
191    public CmsXmlConfigUpdater(File xsltDir, File configDir) {
192
193        m_configDir = configDir;
194        m_xsltDir = xsltDir;
195        m_parserFactory.setNamespaceAware(true);
196        m_parserFactory.setValidating(false);
197        m_transformerFactory.setURIResolver(new EntityIgnoringUriResolver());
198    }
199
200    /**
201     * Helper method for determining the position for a top-level configuration element in opencms-system.xml.
202     *
203     * <p>This can be used by XSL transformations to insert optional nodes for new features on the top level.
204     * @param name the element name
205     * @return the position for the element name, or -1 if the position could not be determined
206     *
207     * @throws Exception if something goes wrong
208     */
209    public static int getSystemConfigPosition(String name) throws Exception {
210
211        byte[] fileData = CmsFileUtil.readFully(
212            CmsConfigurationManager.class.getResourceAsStream("opencms-system.dtd"),
213            true);
214        String dtdText = new String(fileData, StandardCharsets.UTF_8);
215        // Assumption: declaration of 'system' in the DTD is just a list of elements (with +/*/? suffixes), and doesn't have nested expressions
216        String regex = "(?s)<!ELEMENT +system +\\(([^()]*?)\\)>";
217        Pattern p = Pattern.compile(regex);
218        Matcher m = p.matcher(dtdText);
219        List<String> elementNames = new ArrayList<>();
220        if (m.find()) {
221            String items = m.group(1);
222            for (String token : items.split("(?:\\s|,)+")) {
223                token = token.trim();
224                if (token.length() == 0) {
225                    continue;
226                }
227                if (token.endsWith("*") || token.endsWith("?") || token.endsWith("+")) {
228                    token = token.substring(0, token.length() - 1);
229                }
230                elementNames.add(token);
231            }
232            return elementNames.indexOf(name);
233        }
234        return -1;
235    }
236
237    /**
238     * Checks if updater has tried to transform.<p>
239     *
240     * @return boolean
241     */
242    public boolean isDone() {
243
244        return m_isDone;
245    }
246
247    /**
248     * Transforms a config file with an XSLT transform.
249     *
250     * @param name file name of the config file
251     * @param transform file name of the XSLT file
252     *
253     * @throws Exception if something goes wrong
254     */
255    public void transform(String name, String transform) throws Exception {
256
257        File configFile = new File(m_configDir, name);
258        File transformFile = new File(m_xsltDir, transform);
259        try (InputStream stream = new FileInputStream(transformFile)) {
260            StreamSource source = new StreamSource(stream);
261            transform(configFile, source);
262        }
263    }
264
265    /**
266     * Transforms the configuration.
267     *
268     * @throws Exception if something goes wrong
269     */
270    public void transformConfig() throws Exception {
271
272        List<TransformEntry> entries = readTransformEntries(new File(m_xsltDir, "transforms.xml"));
273        for (TransformEntry entry : entries) {
274            transform(entry.getConfigFile(), entry.getXslt());
275        }
276        m_isDone = true;
277    }
278
279    /**
280     * Gets validation errors either as a JSON string, or null if there are no validation errors.
281     *
282     * @return the validation error JSON
283     */
284    public String validationErrors() {
285
286        List<String> errors = new ArrayList<>();
287        for (File config : getConfigFiles()) {
288            String filename = config.getName();
289            try (FileInputStream stream = new FileInputStream(config)) {
290                CmsXmlUtils.unmarshalHelper(CmsFileUtil.readFully(stream, false), new CmsXmlEntityResolver(null), true);
291            } catch (CmsXmlException e) {
292                errors.add(filename + ":" + e.getCause().getMessage());
293            } catch (Exception e) {
294                errors.add(filename + ":" + e.getMessage());
295            }
296        }
297        if (errors.size() == 0) {
298            return null;
299        }
300        String errString = CmsStringUtil.listAsString(errors, "\n");
301        JSONObject obj = new JSONObject();
302        try {
303            obj.put("err", errString);
304        } catch (JSONException e) {
305
306        }
307        return obj.toString();
308    }
309
310    /**
311     * Gets existing config files.
312     *
313     * @return the existing config files
314     */
315    private List<File> getConfigFiles() {
316
317        String[] filenames = {
318            "opencms-modules.xml",
319            "opencms-system.xml",
320            "opencms-vfs.xml",
321            "opencms-importexport.xml",
322            "opencms-sites.xml",
323            "opencms-variables.xml",
324            "opencms-scheduler.xml",
325            "opencms-workplace.xml",
326            "opencms-search.xml"};
327        List<File> result = new ArrayList<>();
328        for (String fn : filenames) {
329            File file = new File(m_configDir, fn);
330            if (file.exists()) {
331                result.add(file);
332            }
333        }
334        return result;
335    }
336
337    /**
338     * Reads entries from transforms.xml.
339     *
340     * @param file the XML file
341     * @return the transform entries read from the file
342     *
343     * @throws Exception if something goes wrong
344     */
345    private List<TransformEntry> readTransformEntries(File file) throws Exception {
346
347        List<TransformEntry> result = new ArrayList<>();
348        try (FileInputStream fis = new FileInputStream(file)) {
349            byte[] data = CmsFileUtil.readFully(fis, false);
350            Document doc = CmsXmlUtils.unmarshalHelper(data, null, false);
351            for (Node node : doc.selectNodes("//transform")) {
352                Element elem = ((Element)node);
353                String xslt = elem.attributeValue("xslt");
354                String conf = elem.attributeValue("config");
355                TransformEntry entry = new TransformEntry(conf, xslt);
356                result.add(entry);
357            }
358        }
359        return result;
360    }
361
362    /**
363     * Transforms a single configuration file using the given transformation source.
364     *
365     * @param file the configuration file
366     * @param transformSource the transform soruce
367     *
368     * @throws TransformerConfigurationException -
369     * @throws IOException -
370     * @throws SAXException -
371     * @throws TransformerException -
372     * @throws ParserConfigurationException -
373     */
374    private void transform(File file, Source transformSource)
375    throws TransformerConfigurationException, IOException, SAXException, TransformerException,
376    ParserConfigurationException {
377
378        Transformer transformer = m_transformerFactory.newTransformer(transformSource);
379        transformer.setOutputProperty(OutputKeys.ENCODING, "us-ascii");
380        transformer.setOutputProperty(OutputKeys.INDENT, "yes");
381        transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");
382        String configDirPath = m_configDir.getAbsolutePath();
383        configDirPath = configDirPath.replaceFirst("[/\\\\]$", "");
384        transformer.setParameter("configDir", configDirPath);
385        XMLReader reader = m_parserFactory.newSAXParser().getXMLReader();
386        reader.setEntityResolver(NO_ENTITY_RESOLVER);
387
388        Source source;
389
390        if (file.exists()) {
391            source = new SAXSource(reader, new InputSource(file.getCanonicalPath()));
392        } else {
393            source = new SAXSource(reader, new InputSource(new ByteArrayInputStream(DEFAULT_XML.getBytes("UTF-8"))));
394        }
395
396        ByteArrayOutputStream baos = new ByteArrayOutputStream();
397        Result target = new StreamResult(baos);
398        transformer.transform(source, target);
399        byte[] transformedConfig = baos.toByteArray();
400        try (FileOutputStream output = new FileOutputStream(file)) {
401            output.write(transformedConfig);
402        }
403    }
404
405}