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