001/*
002 *  Copyright 2013 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.plugins.skincommons;
017
018import java.io.File;
019import java.io.IOException;
020import java.io.InputStream;
021import java.nio.file.Files;
022import java.nio.file.Path;
023import java.text.DateFormat;
024import java.text.SimpleDateFormat;
025import java.util.Arrays;
026import java.util.Date;
027import java.util.stream.Stream;
028
029import javax.xml.xpath.XPath;
030import javax.xml.xpath.XPathExpressionException;
031import javax.xml.xpath.XPathFactory;
032
033import org.apache.avalon.framework.component.Component;
034import org.apache.avalon.framework.component.ComponentException;
035import org.apache.avalon.framework.context.Context;
036import org.apache.avalon.framework.context.ContextException;
037import org.apache.avalon.framework.context.Contextualizable;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.avalon.framework.service.Serviceable;
041import org.apache.avalon.framework.thread.ThreadSafe;
042import org.apache.cocoon.Constants;
043import org.apache.cocoon.i18n.BundleFactory;
044import org.apache.commons.io.FileUtils;
045import org.apache.commons.io.comparator.LastModifiedFileComparator;
046import org.apache.commons.lang.StringUtils;
047import org.apache.commons.lang3.RandomStringUtils;
048import org.xml.sax.InputSource;
049
050import org.ametys.core.cocoon.XMLResourceBundleFactory;
051import org.ametys.core.util.path.PathUtils;
052import org.ametys.runtime.plugin.component.AbstractLogEnabled;
053import org.ametys.runtime.servlet.RuntimeConfig;
054import org.ametys.web.cache.CacheHelper;
055import org.ametys.web.cache.pageelement.PageElementCache;
056import org.ametys.web.cocoon.I18nTransformer;
057import org.ametys.web.repository.site.Site;
058import org.ametys.web.repository.site.SiteManager;
059import org.ametys.web.skin.SkinsManager;
060
061/**
062 * Helper for skin edition
063 *
064 */
065public class SkinEditionHelper extends AbstractLogEnabled implements Component, ThreadSafe, Serviceable, Contextualizable
066{
067    /** The Avalon role name */
068    public static final String ROLE = SkinEditionHelper.class.getName();
069    
070    private static final DateFormat _DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd-HHmmss");
071    
072    /** The cocoon context */
073    protected org.apache.cocoon.environment.Context _cocoonContext;
074    
075    private SiteManager _siteManager;
076    private PageElementCache _zoneItemCache;
077    private PageElementCache _inputDataCache;
078    private XMLResourceBundleFactory _i18nFactory;
079    private SkinsManager _skinsManager;
080    
081    @Override
082    public void service(ServiceManager smanager) throws ServiceException
083    {
084        _siteManager = (SiteManager) smanager.lookup(SiteManager.ROLE);
085        _zoneItemCache = (PageElementCache) smanager.lookup(PageElementCache.ROLE + "/zoneItem");
086        _inputDataCache = (PageElementCache) smanager.lookup(PageElementCache.ROLE + "/inputData");
087        _i18nFactory = (XMLResourceBundleFactory) smanager.lookup(BundleFactory.ROLE);
088        _skinsManager = (SkinsManager) smanager.lookup(SkinsManager.ROLE);
089    }
090    
091    @Override
092    public void contextualize(Context context) throws ContextException
093    {
094        _cocoonContext = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
095    }
096    
097    /**
098     * Create a backup file of the current skin
099     * @param skinName The skin name
100     * @return The created backup directory
101     * @throws IOException If an error occurred
102     */
103    public Path createBackupFile (String skinName) throws IOException
104    {
105        Path backupDir = getBackupDirectory(skinName, new Date());
106        PathUtils.moveDirectoryToDirectory(getSkinDirectory(skinName), backupDir, true);
107        return backupDir;
108    }
109    
110    /**
111     * Asynchronous file deletion
112     * @param file The file to delete
113     * @return <code>true</code> if the deletion succeeded
114     * @throws IOException if an error occurs while manipulating files
115     */
116    public boolean deleteQuicklyDirectory(Path file) throws IOException
117    {
118        Path toDelete = file.getParent().resolve(file.getFileName() + "_todelete_" + RandomStringUtils.random(10, false, true));
119        
120        try 
121        {
122            // Move file
123            Files.move(file, toDelete);
124            
125            // Then delete it in asynchronous mode
126            Thread th = new Thread(new AsynchronousPathDeletion(toDelete));
127            th.start();
128            
129            return true;
130        }
131        catch (IOException e)
132        {
133            return false;
134        }
135    }
136    
137    /**
138     * Remove the old backup
139     * @param skinName The skin name
140     * @param keepMax The max number of backup to keep
141     * @throws IOException if an error occurs while manipulating files
142     */
143    public void deleteOldBackup (String skinName, int keepMax) throws IOException
144    {
145        // Remove old backup (keep only the 5 last backup)
146        File rootBackupDir = getRootBackupDirectory(skinName);
147        File[] allBackup = rootBackupDir.listFiles();
148        Arrays.sort(allBackup, LastModifiedFileComparator.LASTMODIFIED_REVERSE);
149        
150        int index = 0;
151        for (File f : allBackup)
152        {
153            if (index > keepMax - 1)
154            {
155                deleteQuicklyDirectory(f.toPath());
156            }
157            index++;
158        }
159    }
160    
161    /**
162     * Invalidate all caches relative to the modified skin.
163     * @param skinName the modified skin name.
164     * @throws Exception if an error occurs.
165     */
166    public void invalidateCaches(String skinName) throws Exception
167    {
168        Exception ex = null;
169        
170        // Invalidate the caches for sites with the skin.
171        for (Site site : _siteManager.getSites())
172        {
173            if (skinName.equals(_getSkinId(site)))
174            {
175                try
176                {
177                    String siteName = site.getName();
178                    
179                    // Invalidate static cache.
180                    CacheHelper.invalidateCache(site, getLogger());
181                    
182                    // Invalidate the page elements caches.
183                    _zoneItemCache.clear(null, siteName);
184                    _inputDataCache.clear(null, siteName);
185                }
186                catch (Exception e)
187                {
188                    getLogger().error("Error clearing the cache for site " + site.toString());
189                    ex = e;
190                }
191            }
192        }
193        
194        // If an exception was thrown, re-throw it.
195        if (ex != null)
196        {
197            throw ex;
198        }
199        
200        invalidateSkinCatalogues(skinName);
201    }
202    
203    private String _getSkinId(Site site)
204    {
205        return site.getSkinId();
206    }
207    
208    /**
209     * Invalidate all catalogs of the temporary skin
210     * @param skinName The site name
211     */
212    public void invalidateTempSkinCatalogues(String skinName)
213    {
214        _invalidateSkinCatalogues(getTempDirectory(skinName), skinName, "ametys-home://skins/temp/" + skinName + "/i18n");
215    }
216    
217    /**
218     * Invalidate a catalog of the temporary skin for a given language
219     * @param skinName The site name
220     * @param lang The language
221     */
222    public void invalidateTempSkinCatalogue(String skinName, String lang)
223    {
224        _invalidateSkinCatalogue(getTempDirectory(skinName), skinName, "ametys-home://skins/temp/" + skinName + "/i18n", lang);
225    }
226    
227    /**
228     * Invalidate all catalogs of the skin
229     * @param skinName The skin name
230     */
231    public void invalidateSkinCatalogues(String skinName)
232    {
233        _invalidateSkinCatalogues(getSkinDirectory(skinName), skinName, "skins:" + skinName + "://i18n");
234    }
235    
236    /**
237     * Invalidate a catalog of the skin for a given language
238     * @param skinName The site name
239     * @param lang The language
240     */
241    public void invalidateSkinCatalogue(String skinName, String lang)
242    {
243        _invalidateSkinCatalogue(getSkinDirectory(skinName), skinName, "skins:" + skinName + "://i18n", lang);
244    }
245    
246    /**
247     * Invalidate all catalogs of the skin
248     * @param skinDir The skin directory
249     * @param skinName The skin name
250     * @param catalogLocation the catalog location
251     */
252    private void _invalidateSkinCatalogues(Path skinDir, String skinName, String catalogLocation)
253    {
254        // Invalidate the i18n cache.
255        I18nTransformer.needsReload();
256        
257        Path i18nDir = skinDir.resolve("i18n");
258        
259        try (Stream<Path> s = Files.list(i18nDir))
260        {
261            s.filter(Files::isRegularFile)
262                 .forEach(i18nFile -> 
263                 {
264                     String filename = i18nFile.getFileName().toString();
265                     if (filename.equals("messages.xml"))
266                     {
267                         _invalidateSkinCatalogue(skinDir, skinName, catalogLocation, StringUtils.EMPTY);
268                     }
269                     else if (filename.startsWith("messages_"))
270                     {
271                         String lang = filename.substring("messages_".length(), "messages_".length() + 2);
272                         _invalidateSkinCatalogue(skinDir, skinName, catalogLocation, lang);
273                     }
274                 });
275        }
276        catch (IOException e)
277        {
278            throw new RuntimeException("Cannot invalidate skin catalogs for skin " + skinName + " and location " + catalogLocation, e);
279        }
280    }
281    
282    /**
283     * Invalidate catalog of the skin
284     * @param skinDir The skin directory
285     * @param skinName The site name
286     * @param catalogLocation the catalog location
287     * @param lang The language of catalog. Can be empty.
288     */
289    private void _invalidateSkinCatalogue(Path skinDir, String skinName, String catalogLocation, String lang)
290    {
291        try
292        {
293            String localName = lang;
294            if (StringUtils.isNotEmpty(lang))
295            {
296                Path f = skinDir.resolve("i18n/messages_" + lang + ".xml");
297                if (!Files.exists(f))
298                {
299                    localName = "";
300                }
301            }
302            
303            _i18nFactory.invalidateCatalogue(catalogLocation, "messages", localName);
304        }
305        catch (ComponentException e)
306        {
307            getLogger().warn("Unable to invalidate i18n catalog for skin " + skinName + " and location " + catalogLocation , e);
308        }
309    }
310    
311    /**
312     * Get the temp directory of skin
313     * @param skinName The skin name
314     * @return The temp directory
315     */
316    public Path getTempDirectory(String skinName)
317    {
318        return RuntimeConfig.getInstance().getAmetysHome().toPath().resolve("skins/temp/" + skinName);
319    }
320    
321    /**
322     * Get the work directory of skin
323     * @param skinName The skin name
324     * @return The work directory
325     */
326    public Path getWorkDirectory(String skinName)
327    {
328        return RuntimeConfig.getInstance().getAmetysHome().toPath().resolve("skins/work/" + skinName);
329    }
330    
331    /**
332     * Get the backup directory of skin
333     * @param skinName The skin name
334     * @param date The date 
335     * @return The backup directory
336     */
337    public Path getBackupDirectory (String skinName, Date date)
338    {
339        String dateStr = _DATE_FORMAT.format(date);
340        return RuntimeConfig.getInstance().getAmetysHome().toPath().resolve("skins/backup/" + skinName + "/" + dateStr);
341    }
342    
343    /**
344     * Get the root backup directory of skin
345     * @param skinName The skin name
346     * @return The root backup directory
347     */
348    public File getRootBackupDirectory (String skinName)
349    {
350        return FileUtils.getFile(RuntimeConfig.getInstance().getAmetysHome(), "skins", "backup", skinName);
351    }
352    
353    /**
354     * Get the temp directory of skin
355     * @param skinName The skin name
356     * @return The temp directory URI
357     */
358    public String getTempDirectoryURI (String skinName)
359    {
360        return "ametys-home://skins/temp/" + skinName;
361    }
362    
363    /**
364     * Get the work directory of skin
365     * @param skinName The skin name
366     * @return The work directory URI
367     */
368    public String getWorkDirectoryURI (String skinName)
369    {
370        return "ametys-home://skins/work/" + skinName;
371    }
372    
373    /**
374     * Get the backup directory of skin
375     * @param skinName The skin name
376     * @param date The date 
377     * @return The backup directory URI
378     */
379    public String getBackupDirectoryURI (String skinName, Date date)
380    {
381        return "ametys-home://skins/backup/" + skinName + "/" + _DATE_FORMAT.format(date);
382    }
383    
384    /**
385     * Get the root backup directory of skin
386     * @param skinName The skin name
387     * @return The root backup directory URI
388     */
389    public String getRootBackupDirectoryURI (String skinName)
390    {
391        return "ametys-home://skins/backup/" + skinName;
392    }
393    
394    /**
395     * Get the skin directory of skin
396     * @param skinName The skin name
397     * @return The skin directory
398     */
399    public Path getSkinDirectory (String skinName)
400    {
401        return _skinsManager.getSkin(skinName).getRawPath();
402    }
403    
404    /**
405     * Get the model of temporary version of skin
406     * @param skinName The skin name
407     * @return the model name
408     */
409    public String getTempModel (String skinName)
410    {
411        return _getModel(getTempDirectory(skinName));
412    }
413    
414    /**
415     * Get the model of working version of skin
416     * @param skinName The skin name
417     * @return the model name
418     */
419    public String getWorkModel (String skinName)
420    {
421        return _getModel (getWorkDirectory(skinName));
422    }
423    
424    /**
425     * Get the model of the skin
426     * @param skinName skinName The skin name
427     * @return The model name or <code>null</code>
428     */
429    public String getSkinModel (String skinName)
430    {
431        return _getModel(getSkinDirectory(skinName));
432    }
433    
434    private String _getModel(Path skinDir)
435    {
436        Path modelFile = skinDir.resolve("model.xml");
437        if (!Files.exists(modelFile))
438        {
439            // No model
440            return null;
441        }
442
443        try (InputStream is = Files.newInputStream(modelFile))
444        {
445            XPath xpath = XPathFactory.newInstance().newXPath();
446            return xpath.evaluate("model/@id", new InputSource(is));
447        }
448        catch (IOException e)
449        {
450            getLogger().error("Can not determine the model of the skin", e);
451            return null;
452        }
453        catch (XPathExpressionException e)
454        {
455            throw new IllegalStateException("The id of model is missing", e);
456        }
457    }
458}