001/*
002 *  Copyright 2015 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.web.skin;
017
018import java.io.BufferedReader;
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.InputStreamReader;
022import java.io.OutputStream;
023import java.net.URL;
024import java.nio.file.FileSystem;
025import java.nio.file.Files;
026import java.nio.file.Path;
027import java.util.Enumeration;
028import java.util.HashMap;
029import java.util.HashSet;
030import java.util.Map;
031import java.util.Properties;
032import java.util.Set;
033import java.util.stream.Stream;
034
035import javax.xml.transform.OutputKeys;
036import javax.xml.transform.TransformerConfigurationException;
037import javax.xml.transform.TransformerFactory;
038import javax.xml.transform.sax.SAXTransformerFactory;
039import javax.xml.transform.sax.TransformerHandler;
040import javax.xml.transform.stream.StreamResult;
041import javax.xml.xpath.XPath;
042import javax.xml.xpath.XPathExpressionException;
043import javax.xml.xpath.XPathFactory;
044
045import org.apache.avalon.framework.activity.Initializable;
046import org.apache.avalon.framework.component.Component;
047import org.apache.avalon.framework.configuration.Configuration;
048import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
049import org.apache.avalon.framework.context.Context;
050import org.apache.avalon.framework.context.ContextException;
051import org.apache.avalon.framework.context.Contextualizable;
052import org.apache.avalon.framework.logger.AbstractLogEnabled;
053import org.apache.avalon.framework.service.ServiceException;
054import org.apache.avalon.framework.service.ServiceManager;
055import org.apache.avalon.framework.service.Serviceable;
056import org.apache.avalon.framework.thread.ThreadSafe;
057import org.apache.cocoon.Constants;
058import org.apache.cocoon.util.HashUtil;
059import org.apache.cocoon.xml.XMLUtils;
060import org.apache.excalibur.source.Source;
061import org.apache.excalibur.source.SourceNotFoundException;
062import org.apache.excalibur.source.SourceResolver;
063import org.xml.sax.InputSource;
064import org.xml.sax.SAXException;
065import org.xml.sax.helpers.AttributesImpl;
066
067import org.ametys.core.util.JarFSManager;
068import org.ametys.runtime.servlet.RuntimeServlet;
069
070/**
071 * Manages the models of skin
072 */
073public class SkinModelsManager extends AbstractLogEnabled implements ThreadSafe, Serviceable, Component, Contextualizable, Initializable
074{
075    /** The avalon role name */
076    public static final String ROLE = SkinModelsManager.class.getName();
077
078    /** The set of templates classified by skins */
079    protected Map<Path, SkinModel> _models = new HashMap<>();
080    
081    /** The models declared as external: name of the models and file location */
082    protected Map<String, Path> _externalSkinModels = new HashMap<>();
083    
084    /** The models declared in jar files */
085    protected Map<String, Path> _resourcesSkinModels = new HashMap<>();
086    
087    /** The avalon service manager */
088    protected ServiceManager _manager;
089    /** The excalibur source resolver */
090    protected SourceResolver _sourceResolver;
091    /** The sites manager */
092    protected SkinsManager _skinsManager;
093    /** Avalon context */
094    protected Context _context;
095    /** Cocoon context */
096    protected org.apache.cocoon.environment.Context _cocoonContext;
097    
098    @Override
099    public void service(ServiceManager manager) throws ServiceException
100    {
101        _manager = manager;
102        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
103        _skinsManager = (SkinsManager) manager.lookup(SkinsManager.ROLE);
104    }
105    
106    @Override
107    public void contextualize(Context context) throws ContextException
108    {
109        _context = context;
110        _cocoonContext = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
111    }
112    
113    public void initialize() throws Exception
114    {
115        // Skins can be in external locations
116        _listExternalSkinModels("context://" + RuntimeServlet.EXTERNAL_LOCATIONS);
117        
118        // Skins can be in jars
119        _listResourcesSkinModels();
120    }
121    private void _listResourcesSkinModels() throws IOException
122    {
123        Enumeration<URL> skinModelResources = getClass().getClassLoader().getResources("META-INF/ametys-models");
124        
125        while (skinModelResources.hasMoreElements())
126        {
127            URL skinModelResource = skinModelResources.nextElement();
128            
129            try (InputStream is = skinModelResource.openStream();
130                 BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8")))
131            {
132                String skinModel;
133                while ((skinModel = br.readLine()) != null)
134                {
135                    int i = skinModel.indexOf(':');
136                    if (i != -1)
137                    {
138                        String skinModelName = skinModel.substring(0, i);       
139                        String skinModelResourceURI = skinModel.substring(i + 1);
140                        
141                        FileSystem skinFileSystem = JarFSManager.getInstance().getFileSystemByResource(skinModelResourceURI);
142                        Path skinModelPath = skinFileSystem.getPath(skinModelResourceURI);
143                        
144                        if (_isASkinModelPath(skinModelPath))
145                        {
146                            _resourcesSkinModels.put(skinModelName, skinModelPath);
147                        }
148                        else
149                        {
150                            getLogger().error("The skin model '" + skinModelName + "' declared in a JAR file will be ignored as it is not a true model");
151                        }
152                    }
153                }
154            }
155        }        
156    }
157
158    private void _listExternalSkinModels(String uri) throws Exception
159    {
160        Configuration externalConf;
161        
162        // Read file
163        Source externalLocation = null;
164        try
165        {
166            externalLocation = _sourceResolver.resolveURI(uri);
167            if (!externalLocation.exists())
168            {
169                throw new SourceNotFoundException("No file at " + uri);
170            }
171            
172            DefaultConfigurationBuilder externalConfBuilder = new DefaultConfigurationBuilder();
173            try (InputStream external = externalLocation.getInputStream())
174            {
175                externalConf = externalConfBuilder.build(external, uri);
176            }
177        }
178        catch (SourceNotFoundException e)
179        {
180            getLogger().debug("No external location file");
181            return;
182        }
183        finally
184        {
185            _sourceResolver.release(externalLocation);
186        }
187        
188        // Apply file
189        for (Configuration skinModelConf : externalConf.getChild("models").getChildren("model"))
190        {
191            String name = skinModelConf.getAttribute("name", null);
192            String location = skinModelConf.getValue(null);
193            
194            if (name != null && location != null)
195            {
196                Path skinModelDir = _getPath(location);
197                // Do not check at this time, since it is not read-only, this can change
198                _externalSkinModels.put(name, skinModelDir);
199            }
200        }
201    }
202    
203    /*
204     * Returns the corresponding file, either absolute or relative to the context path
205     */
206    private Path _getPath(String path)
207    {
208        if (path == null)
209        {
210            return null;
211        }
212        
213        Path directory = Path.of(path);
214        if (directory.isAbsolute())
215        {
216            return directory;
217        }
218        else
219        {
220            return Path.of(_cocoonContext.getRealPath("/" + path));
221        }
222    }
223
224    
225    /**
226     * Get the list of existing models
227     * @return A set of model names. Can be null if there is an error.
228     */
229    public Set<String> getModels()
230    {
231        try
232        {
233            Set<String> skinModels = new HashSet<>();
234            
235            // JAR skins
236            skinModels.addAll(_resourcesSkinModels.keySet());
237            
238            // External skins
239            _externalSkinModels.entrySet().stream()
240                          .filter(e -> _isASkinModelPath(e.getValue()))
241                          .map(Map.Entry::getKey)
242                          .forEach(skinModels::add);
243            
244            // Skins at location
245            Path skinModelsDir = getLocalModelsLocation();
246            if (Files.exists(skinModelsDir) && Files.isDirectory(skinModelsDir))
247            {
248                try (Stream<Path> files = Files.list(skinModelsDir))
249                {
250                    files.filter(this::_isASkinModelPath)
251                        .map(p -> p.getFileName().toString())
252                        .forEach(skinModels::add);
253                }
254            }
255            
256            return skinModels;
257        }
258        catch (Exception e)
259        {
260            getLogger().error("Can not determine the list of available models", e);
261            return null;
262        }        
263    }
264    
265    /**
266     * Get a model
267     * @param id The id of the model
268     * @return The model or null if the model does not exists
269     */
270    public SkinModel getModel(String id)
271    {
272        Path skinModelPath;
273        boolean modifiable = false;
274        
275        skinModelPath = _resourcesSkinModels.get(id);
276        if (skinModelPath == null)
277        {
278            skinModelPath = _externalSkinModels.get(id);
279            if (skinModelPath == null)
280            {
281                skinModelPath = getLocalModelsLocation().resolve(id);
282                if (Files.exists(skinModelPath) && Files.isDirectory(skinModelPath))
283                {
284                    modifiable = true;
285                }
286                else
287                {
288                    // No skin with this name
289                    return null;
290                }
291            }
292        }
293        
294        if (!_isASkinModelPath(skinModelPath))
295        {
296            // A model with this name but is not a model
297            _models.put(skinModelPath, null);
298            return null;
299        }
300        
301        SkinModel skinModel = _models.get(skinModelPath);
302        if (skinModel == null)
303        {
304            skinModel = new SkinModel(id, skinModelPath, modifiable);
305            _models.put(skinModelPath, skinModel);
306        }
307        
308        skinModel.refreshValues();
309        return skinModel;
310    }
311    
312    /**
313     * Get the id of model associated to a skin
314     * @param skin The skin
315     * @return The id of the model or <code>null</code> if there is no model for this skin 
316     */
317    public String getModelOfSkin(Skin skin)
318    {
319        Path modelFile = skin.getRawPath().resolve("model.xml"); // Do not support model by inheritance, since unlinking the model or applying it will be local
320        if (!Files.exists(modelFile))
321        {
322            // No model
323            return null;
324        }
325        
326        try (InputStream is = Files.newInputStream(modelFile))
327        {
328            XPath xpath = XPathFactory.newInstance().newXPath();
329            return xpath.evaluate("model/@id", new InputSource(is));
330        }
331        catch (XPathExpressionException e)
332        {
333            throw new IllegalStateException("The id of model is missing", e);
334        }
335        catch (IOException e)
336        {
337            getLogger().error("Can not determine the model of the skin", e);
338            return null;
339        }
340        
341    }
342    /**
343     * Get hash from model
344     * @param id The id of the model
345     * @return unique has
346     */
347    public String getModelHash (String id)
348    {
349        SkinModel model = getModel(id);
350        Path prefix = model.getPath().getParent();
351        
352        StringBuffer sb = new StringBuffer();
353
354        try
355        {
356            Files.walk(model.getPath())
357                 .forEach(child ->
358                 {
359                     try
360                     {
361                         sb.append(prefix.relativize(child).toString())
362                           .append("-")
363                           .append(Files.getLastModifiedTime(child).toMillis()).append(";");
364                     }
365                     catch (IOException e)
366                     {
367                         throw new RuntimeException("Cannot compute model hash for " + id, e);
368                     }
369                 });
370        }
371        catch (IOException e)
372        {
373            throw new RuntimeException("Cannot compute model hash for " + id, e);
374        }
375        
376        long hash = Math.abs(HashUtil.hash(sb));
377        return Long.toString(hash, 64);
378    }
379    
380    /**
381     * Generates the model.xml file for the skin
382     * @param skinDir The skin directory
383     * @param modelId The model id
384     * @param colorTheme The id of color theme. Can be null.
385     * @throws IOException if an I/O exception occurs during generation
386     * @throws SAXException if an error occurs during generation
387     * @throws TransformerConfigurationException if an error occurs during generation
388     */
389    public void generateModelFile (Path skinDir, String modelId, String colorTheme) throws IOException, SAXException, TransformerConfigurationException
390    {
391        Path modelFile = skinDir.resolve("model.xml");
392        try (OutputStream os = Files.newOutputStream(modelFile))
393        {
394            // create a transformer for saving sax into a file
395            TransformerHandler th = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler();
396            // create the result where to write
397            StreamResult sResult = new StreamResult(os);
398            th.setResult(sResult);
399
400            // create the format of result
401            Properties format = new Properties();
402            format.put(OutputKeys.METHOD, "xml");
403            format.put(OutputKeys.INDENT, "yes");
404            format.put(OutputKeys.ENCODING, "UTF-8");
405            th.getTransformer().setOutputProperties(format);
406
407            // Send SAX events
408            th.startDocument();
409
410            // Hash for model
411            String hash = getModelHash(modelId);
412            
413            AttributesImpl attrs = new AttributesImpl();
414            attrs.addAttribute("", "id", "id", "CDATA", modelId);
415            attrs.addAttribute("", "hash", "hash", "CDATA", hash);
416            XMLUtils.startElement(th, "model", attrs);
417            
418            XMLUtils.createElement(th, "parameters", "\n");
419            
420            if (colorTheme != null)
421            {
422                XMLUtils.createElement(th, "color-theme", colorTheme);
423            }
424            
425            XMLUtils.endElement(th, "model");
426            
427            th.endDocument();
428        }
429    }
430    
431    /**
432     * Generates the model.xml file for the skin
433     * @param skinDir The skin directory
434     * @param modelId The model id
435     * @throws IOException if an I/O exception occurs during generation
436     * @throws SAXException if an error occurs during generation 
437     * @throws TransformerConfigurationException if an error occurs during generation 
438     */
439    public void generateModelFile (Path skinDir, String modelId) throws IOException, SAXException, TransformerConfigurationException
440    {
441        generateModelFile(skinDir, modelId, null);
442    }
443    
444    /**
445     * Get the skins location
446     * @return the skin location
447     */
448    public Path getLocalModelsLocation ()
449    {
450        return Path.of(_cocoonContext.getRealPath("/models"));
451    }
452    
453    private boolean _isASkinModelPath(Path modelDir)
454    {
455        if (!Files.exists(modelDir) || !Files.isDirectory(modelDir))
456        {
457            return false;
458        }
459        
460        Path model = modelDir.resolve("model");
461        return Files.exists(model) && Files.isDirectory(model);
462    }
463}