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