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.BufferedOutputStream;
019import java.io.IOException;
020import java.io.OutputStream;
021import java.nio.file.Files;
022import java.nio.file.Path;
023import java.util.ArrayList;
024import java.util.Collection;
025import java.util.HashMap;
026import java.util.List;
027import java.util.Map;
028import java.util.Map.Entry;
029import java.util.Properties;
030import java.util.function.Predicate;
031import java.util.stream.Collectors;
032
033import javax.xml.transform.OutputKeys;
034import javax.xml.transform.Result;
035import javax.xml.transform.TransformerConfigurationException;
036import javax.xml.transform.TransformerFactory;
037import javax.xml.transform.sax.SAXTransformerFactory;
038import javax.xml.transform.sax.TransformerHandler;
039import javax.xml.transform.stream.StreamResult;
040
041import org.apache.avalon.framework.component.Component;
042import org.apache.avalon.framework.configuration.Configuration;
043import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
044import org.apache.avalon.framework.context.Context;
045import org.apache.avalon.framework.context.ContextException;
046import org.apache.avalon.framework.context.Contextualizable;
047import org.apache.avalon.framework.logger.AbstractLogEnabled;
048import org.apache.avalon.framework.service.ServiceException;
049import org.apache.avalon.framework.service.ServiceManager;
050import org.apache.avalon.framework.service.Serviceable;
051import org.apache.cocoon.ProcessingException;
052import org.apache.cocoon.xml.AttributesImpl;
053import org.apache.cocoon.xml.XMLUtils;
054import org.apache.commons.io.FileUtils;
055import org.apache.xml.serializer.OutputPropertiesFactory;
056import org.slf4j.LoggerFactory;
057import org.xml.sax.SAXException;
058
059import org.ametys.core.ui.Callable;
060import org.ametys.core.util.path.PathUtils;
061import org.ametys.plugins.repository.AmetysObjectIterable;
062import org.ametys.runtime.model.CategorizedElementDefinitionHelper;
063import org.ametys.runtime.model.DefinitionAndValue;
064import org.ametys.runtime.model.ElementDefinition;
065import org.ametys.runtime.model.disableconditions.DisableConditionsEvaluator;
066import org.ametys.runtime.model.type.ElementType;
067import org.ametys.runtime.parameter.ValidationResults;
068import org.ametys.runtime.util.AmetysHomeHelper;
069import org.ametys.web.cocoon.I18nTransformer;
070import org.ametys.web.cocoon.I18nUtils;
071import org.ametys.web.data.type.ModelItemTypeExtensionPoint;
072import org.ametys.web.impl.model.type.xsl.XSLElementType;
073import org.ametys.web.repository.site.Site;
074import org.ametys.web.repository.site.SiteManager;
075
076/**
077 * DAO for manipulating skins
078 */
079public class SkinDAO extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
080{
081    /** The avalon role*/
082    public static final String ROLE = SkinDAO.class.getName();
083    
084    private static final String __LICENCE = "\n"
085            + "   Copyright 2012 Anyware Services\n"
086            + "\n"
087            + "   Licensed under the Apache License, Version 2.0 (the \"License\");\n"
088            + "   you may not use this file except in compliance with the License.\n"
089            + "   You may obtain a copy of the License at\n"
090            + "\n"
091            + "       http://www.apache.org/licenses/LICENSE-2.0\n"
092            + "\n"
093            + "   Unless required by applicable law or agreed to in writing, software\n"
094            + "   distributed under the License is distributed on an \"AS IS\" BASIS,\n"
095            + "   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n"
096            + "   See the License for the specific language governing permissions and\n"
097            + "   limitations under the License.\n"
098            + "\n"; 
099
100    /** The service manager instance */
101    protected ServiceManager _manager;
102    private Context _context;
103    private SkinsManager _skinsManager;
104    private SkinModelsManager _modelsManager;
105    private SiteManager _siteManager;
106    private I18nUtils _i18nUtils;
107    private ModelItemTypeExtensionPoint _skinParameterTypeEP;
108    private DisableConditionsEvaluator<Skin> _disableConditionsEvaluator;
109
110    @SuppressWarnings("unchecked")
111    @Override
112    public void service(ServiceManager manager) throws ServiceException
113    {
114        _manager = manager;
115        _skinsManager = (SkinsManager) manager.lookup(SkinsManager.ROLE);
116        _modelsManager = (SkinModelsManager) manager.lookup(SkinModelsManager.ROLE);
117        _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE);
118        _i18nUtils = (I18nUtils) manager.lookup(org.ametys.core.util.I18nUtils.ROLE);
119        _skinParameterTypeEP = (ModelItemTypeExtensionPoint) manager.lookup(ModelItemTypeExtensionPoint.ROLE_SKIN_PARAM);
120        _disableConditionsEvaluator = (DisableConditionsEvaluator<Skin>) manager.lookup(SkinDisableConditionsEvaluator.ROLE);
121    }
122    
123    public void contextualize(Context context) throws ContextException
124    {
125        _context = context;
126    }
127    /**
128     * Retrieve informations on a skin
129     * @param skinId The skin id
130     * @return the informations of a skin
131     */
132    @Callable
133    public Map<String, Object> getSkin(String skinId)
134    {
135        Map<String, Object> result = new HashMap<>();
136
137        Skin skin = _skinsManager.getSkin(skinId);
138
139        if (skin != null)
140        {
141            result.put("id", skin.getId());
142            result.put("label", skin.getLabel());
143            result.put("inUse", isInUse(skinId));
144            result.put("isModifiable", skin.isModifiable());
145            result.put("isConfigurable", skin.isConfigurable());
146            result.put("isExtended", _skinsManager.getDirectChildren(skin).size() > 0);
147    
148            String modelName = _modelsManager.getModelOfSkin(skin);
149            result.put("model", modelName);
150        }
151        
152        return result;
153    }
154    
155    /**
156     * Determines if a skin exists
157     * @param skinId The skin id
158     * @return true if skin exists.
159     * @throws ProcessingException if an error occurs
160     */
161    @Callable
162    public boolean skinExists (String skinId) throws ProcessingException
163    {
164        return _skinsManager.getSkins().contains(skinId);
165    }
166    
167    /**
168     * Determines if a skin is currently used by one or more sites
169     * @param skinId The skin id
170     * @return true if skin is currently in use
171     */
172    @Callable
173    public boolean isInUse (String skinId)
174    {
175        AmetysObjectIterable<Site> sites = _siteManager.getSites();
176        for (Site site : sites)
177        {
178            if (skinId.equals(site.getSkinId()))
179            {
180                return true;
181            }
182            
183        }
184        return false;
185    }
186    
187    /**
188     * Retrieve the list of available skins
189     * @param includeAbstract Should include abstract skins
190     * @return a map of skins
191     */
192    @Callable
193    public Map<String, Object> getSkins(boolean includeAbstract)
194    {
195        Map<String, Object> result = new HashMap<>();
196
197        result.put("skins", _skins2JsonObject(includeAbstract));
198
199        return result;
200    }
201
202    private List<Object> _skins2JsonObject(boolean includeAbstract)
203    {
204        List<Object> skinsList = new ArrayList<>();
205        Map<String, List<Site>> skins = new HashMap<>();
206        for (String id : _skinsManager.getSkins())
207        {
208            if (includeAbstract || !_skinsManager.getSkin(id).isAbstract())
209            {
210                skins.put(id, new ArrayList<>());
211            }
212        }
213
214        AmetysObjectIterable<Site> sites = _siteManager.getSites();
215        for (Site site : sites)
216        {
217            String skinId = site.getSkinId();
218            if (skinId != null)
219            {
220                List<Site> skin = skins.get(skinId);
221    
222                if (skin != null)
223                {
224                    skin.add(site);
225                }
226            }
227        }
228
229        for (String id : skins.keySet())
230        {
231            skinsList.add(_skin2JsonObject(id, skins.get(id)));
232        }
233
234        return skinsList;
235    }
236
237    private Map<String, Object> _skin2JsonObject(String id, List<Site> skinSites)
238    {
239        Map<String, Object> jsonSkin = new HashMap<>();
240
241        Skin skin = _skinsManager.getSkin(id);
242
243        jsonSkin.put("id", skin.getId());
244        jsonSkin.put("abstract", skin.isAbstract());
245        jsonSkin.put("label", skin.getLabel());
246        jsonSkin.put("description", skin.getDescription());
247        jsonSkin.put("iconLarge", skin.getLargeImage());
248        jsonSkin.put("iconSmall", skin.getSmallImage());
249        jsonSkin.put("nbSites", skinSites.size());
250        jsonSkin.put("extendingSkins", skin.getParents());
251        jsonSkin.put("extendedIntoSkins", _skinsManager.getDirectChildren(skin).stream().map(Skin::getId).collect(Collectors.toList()));
252        jsonSkin.put("sites", skinSites.stream()
253                .map(site -> site.getTitle() + " (" + site.getName() + ")")
254                .collect(Collectors.joining(", ")));
255
256        String modelName = _modelsManager.getModelOfSkin(skin);
257        if (modelName != null)
258        {
259            SkinModel model = _modelsManager.getModel(modelName);
260            if (model != null)
261            {
262                Map<String, Object> skinModel = new HashMap<>();
263
264                skinModel.put("id", modelName);
265                skinModel.put("name", model.getLabel());
266
267                jsonSkin.put("model", skinModel);
268            }
269        }
270
271        return jsonSkin;
272    }
273
274
275
276    /**
277     * This action receive a form with the "importfile" zip file as an exported skin.
278     * Replace existing skin 
279     * @param skinName The skin name
280     * @param tmpDirPath The directory where the zip was uploaded 
281     * @param values the configuration's values. Can be empty.
282     * @return The skin name
283     * @throws SAXException if an error occurs during configuration file creation
284     * @throws IOException if an error occurs while manipulating files
285     * @throws TransformerConfigurationException if an error occurs during configuration file creation
286     * @throws ProcessingException error while parsing model
287     */
288    @Callable
289    public Map<String, Object> importSkin(String skinName, String tmpDirPath, Map<String, Object> values) throws TransformerConfigurationException, IOException, SAXException, ProcessingException
290    {
291        Map<String, Object> result = new HashMap<>();
292        
293        Path tmpDir = AmetysHomeHelper.getAmetysHomeTmp().toPath().resolve(tmpDirPath);
294        if (Files.isDirectory(tmpDir))
295        {
296            if (!values.isEmpty())
297            {
298                SkinParametersModel model = new SkinParametersModel(skinName, tmpDir, _skinParameterTypeEP, _context, _manager);
299                ValidationResults validationResults = CategorizedElementDefinitionHelper.validateValuesForWriting(values, model.getModelItems(), _disableConditionsEvaluator, LoggerFactory.getLogger(getClass()));
300
301                if (validationResults.hasErrors())
302                {
303                    result.put("errors", validationResults.getAllErrors());
304                    return result;
305                }
306                
307                _createConfigFile(model, tmpDir, values);
308            }
309            
310            // If exists remove.
311            Skin existingSkin = _skinsManager.getSkin(skinName);
312            if (existingSkin != null)
313            {
314                if (existingSkin.isModifiable())
315                {
316                    deleteSkin(skinName);
317                }
318                else
319                {
320                    throw new IllegalStateException("The skin '" + skinName + "' already exists and is not modifiable and thus cannot be replaced.");                    
321                }
322            }
323            
324            // Move to skins directory
325            Path rootLocation = _skinsManager.getLocalSkinsLocation();
326            PathUtils.moveDirectory(tmpDir, rootLocation.resolve(skinName));
327
328            _i18nUtils.reloadCatalogues();
329            I18nTransformer.needsReload();
330        }
331        
332        result.put("skinId", skinName);
333        return result;
334    }
335    
336    /**
337     * Configure a skin
338     * @param skinName the skin name
339     * @param values the configuration's values
340     * @return A map with "errors" key that is a map &lt;errorName&gt; &lt;errorMessage&gt;
341     * @throws SAXException if an error occurs during configuration file creation
342     * @throws IOException if an error occurs during configuration file creation
343     * @throws TransformerConfigurationException if an error occurs during configuration file creation
344     * @throws ProcessingException error while parsing model
345     */
346    @Callable
347    public Map<String, Object> configureSkin (String skinName,  Map<String, Object> values) throws TransformerConfigurationException, IOException, SAXException, ProcessingException
348    {
349        Map<String, Object> result = new HashMap<>();
350        
351        Skin skin = _skinsManager.getSkin(skinName);
352        if (!skin.isConfigurable())
353        {
354            throw new IllegalStateException("The skin '" + skinName + "' is not configurable.");       
355        }
356        
357        Path skinDir = skin.getRawPath(); // isConfigurable works locally only, so we can get the internal path
358        SkinParametersModel model = new SkinParametersModel(skinName, skinDir, _skinParameterTypeEP, _context, _manager);
359        ValidationResults validationResults = CategorizedElementDefinitionHelper.validateValuesForWriting(values, model.getModelItems(), _disableConditionsEvaluator, LoggerFactory.getLogger(getClass()));
360
361        if (validationResults.hasErrors())
362        {
363            result.put("errors", validationResults.getAllErrors());
364            return result;
365        }
366        
367        //Delete the old file only if no error found
368        Path configFile = skinDir.resolve("stylesheets/config/config.xsl");
369        if (Files.exists(configFile))
370        {
371            FileUtils.deleteQuietly(configFile.toFile());
372        }
373        _createConfigFile(model, skinDir, values);
374        
375        _i18nUtils.reloadCatalogues();
376        I18nTransformer.needsReload();
377        
378        result.put("skinId", skinName);
379        return result;
380    }
381    
382    private void _createConfigFile(SkinParametersModel model, Path skinDir, Map<String, Object> values) throws IOException, SAXException, TransformerConfigurationException
383    {
384        Path configFile = skinDir.resolve("stylesheets/config/config.xsl");
385        
386        SAXTransformerFactory factory = (SAXTransformerFactory) TransformerFactory.newInstance();
387        TransformerHandler handler = factory.newTransformerHandler();
388        
389        try (OutputStream os = new BufferedOutputStream(Files.newOutputStream(configFile)))
390        {
391            Result result = new StreamResult(os);
392            
393            Properties format = new Properties();
394            format.put(OutputKeys.METHOD, "xml");
395            format.put(OutputKeys.ENCODING, "UTF-8");
396            format.put(OutputKeys.INDENT, "yes");
397            format.put(OutputKeys.OMIT_XML_DECLARATION, "no");
398            format.put(OutputPropertiesFactory.S_KEY_INDENT_AMOUNT, "4");
399            
400            handler.setResult(result);
401            handler.getTransformer().setOutputProperties(format);
402            
403            handler.startDocument();
404            
405            AttributesImpl attrs = new AttributesImpl();
406            attrs.addCDATAAttribute("version", "1.0");
407            attrs.addCDATAAttribute("xmlns:xsl", "http://www.w3.org/1999/XSL/Transform");
408            
409            handler.comment(__LICENCE.toCharArray(), 0, __LICENCE.length());
410            XMLUtils.startElement(handler, "xsl:stylesheet", attrs);
411            
412            for (Entry<String, Object> variable : values.entrySet())
413            {
414                if (model.hasModelItem(variable.getKey()))
415                {
416                    ElementDefinition elementDefinition = model.getModelItem(variable.getKey());
417                    ElementType type = elementDefinition.getType();
418                    if (type instanceof XSLElementType)
419                    {
420                        ((XSLElementType) type).write(handler, elementDefinition.getName(), variable.getValue());
421                    }
422                }
423            }
424            
425            XMLUtils.endElement(handler, "xsl:stylesheet");
426            
427            handler.endDocument();
428        }
429    }
430    
431    /**
432     * Duplicate an existing skin
433     * @param skinId The new skin id
434     * @param originalSkinId The original skin id
435     * @return An error message, or null if successful
436     * @throws IOException if an I/O exception occurs during copy
437     */
438    @Callable
439    public String copySkin(String skinId, String originalSkinId) throws IOException
440    {
441        // Check if exists
442        if (_skinsManager.getSkins().contains(skinId))
443        {
444            return "already-exists";
445        }
446        
447        Path sourceSkinPath = _skinsManager.getSkin(originalSkinId).getRawPath();
448        Path destinationPath = _skinsManager.getLocalSkinsLocation().resolve(skinId);
449        
450        PathUtils.copyDirectory(sourceSkinPath, destinationPath, _getModelFilter(sourceSkinPath), false);
451        
452        _i18nUtils.reloadCatalogues();
453        I18nTransformer.needsReload();
454        
455        return null;
456    }
457    
458    /**
459     * This filter accepts all <code>File</code>s excepted CVS and SVN directories and root directory named "model"
460     * @param skinDir The model root directory
461     * @return the filter
462     */
463    private static final Predicate<Path> _getModelFilter(Path skinDir)
464    {
465        return f -> 
466                    !f.getFileName().toString().equals("CVS")
467                    && !f.getFileName().toString().equals(".svn")
468                    && (!f.getParent().equals(skinDir)  || !f.getFileName().toString().equals("model"));
469    }
470    
471    /**
472     * Delete a skin
473     * @param skinId The skin id
474     * @return the skin id
475     * @throws IOException if an I/O exception occurs during deletion
476     */
477    @Callable
478    public String deleteSkin(String skinId) throws IOException
479    {
480        Skin skin = _skinsManager.getSkin(skinId);
481        Path file = skin.getRawPath();
482        
483        if (!skin.isModifiable())
484        {
485            throw new IllegalStateException("The skin '" + skinId + "' is not modifiable and thus cannot be removed.");
486        }
487
488        List<Skin> directChildren = _skinsManager.getDirectChildren(skin);
489        if (directChildren.size() > 0)
490        {
491            throw new IllegalStateException("The skin '" + skinId + "' cannot be removed since the skin(s) " + directChildren.stream().map(Skin::getId).collect(Collectors.toList()) + " extend(s) it.");
492        }
493        
494        if (Files.exists(file))
495        {
496            FileUtils.deleteDirectory(file.toFile());
497        }
498        
499        return skinId;
500    }
501    
502    /**
503     * Parse the values file and make a liaison with the definitions, to return a collection of {@link DefinitionAndValue}
504     * @param skinDir folder of the skin
505     * @param skinParameterDefinitions the skin parameter definitions
506     * @return a {@link Collection} of {@link DefinitionAndValue} using flatDefinitions and the values found in skinDir
507     * @throws Exception Impossible to read the file
508     */
509    public Collection<DefinitionAndValue> readValues(Path skinDir, Collection<ElementDefinition> skinParameterDefinitions) throws Exception
510    {
511        Collection<DefinitionAndValue> definitionAndValues = new ArrayList<>();
512        String fileName = "stylesheets/config/config.xsl";
513        Path configFile = skinDir.resolve(fileName);
514        boolean fileExists = Files.exists(configFile);
515
516        if (fileExists)
517        {
518            Configuration configuration = new DefaultConfigurationBuilder().buildFromFile(configFile.toFile());
519            for (ElementDefinition definition : skinParameterDefinitions)
520            {
521                String parameterName = definition.getName();
522                ElementType type = definition.getType();
523                if (type instanceof XSLElementType)
524                {
525                    Object value = ((XSLElementType) type).read(configuration, parameterName);
526                    DefinitionAndValue definitionAndValue = new DefinitionAndValue<>(null, definition, value);
527                    definitionAndValues.add(definitionAndValue);
528                }
529            }
530            
531            return definitionAndValues;
532        }
533        
534        return null;
535    }
536}