001/*
002 *  Copyright 2010 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.net.URL;
023import java.nio.file.FileSystem;
024import java.nio.file.Files;
025import java.nio.file.Path;
026import java.util.ArrayList;
027import java.util.Enumeration;
028import java.util.HashMap;
029import java.util.HashSet;
030import java.util.List;
031import java.util.Map;
032import java.util.Set;
033import java.util.stream.Stream;
034
035import org.apache.avalon.framework.CascadingRuntimeException;
036import org.apache.avalon.framework.activity.Disposable;
037import org.apache.avalon.framework.activity.Initializable;
038import org.apache.avalon.framework.component.Component;
039import org.apache.avalon.framework.configuration.Configuration;
040import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
041import org.apache.avalon.framework.context.Context;
042import org.apache.avalon.framework.context.ContextException;
043import org.apache.avalon.framework.context.Contextualizable;
044import org.apache.avalon.framework.logger.AbstractLogEnabled;
045import org.apache.avalon.framework.service.ServiceException;
046import org.apache.avalon.framework.service.ServiceManager;
047import org.apache.avalon.framework.service.Serviceable;
048import org.apache.avalon.framework.thread.ThreadSafe;
049import org.apache.cocoon.Constants;
050import org.apache.cocoon.components.ContextHelper;
051import org.apache.cocoon.environment.Request;
052import org.apache.commons.lang.StringUtils;
053import org.apache.excalibur.source.Source;
054import org.apache.excalibur.source.SourceNotFoundException;
055import org.apache.excalibur.source.SourceResolver;
056
057import org.ametys.core.util.JarFSManager;
058import org.ametys.runtime.servlet.RuntimeConfig;
059import org.ametys.runtime.servlet.RuntimeServlet;
060import org.ametys.web.WebConstants;
061import org.ametys.web.WebHelper;
062import org.ametys.web.parameters.ViewAndParametersParser;
063import org.ametys.web.parameters.view.ViewParameterTypeExtensionPoint;
064import org.ametys.web.parameters.view.ViewParametersManager;
065import org.ametys.web.repository.site.SiteManager;
066
067/**
068 * Manages the skins
069 */
070public class SkinsManager extends AbstractLogEnabled implements ThreadSafe, Serviceable, Component, Contextualizable, Initializable, Disposable
071{
072    /** The avalon role name */
073    public static final String ROLE = SkinsManager.class.getName();
074
075    /** A cache of skins objects classified by absolute path */
076    protected Map<Path, Skin> _skins = new HashMap<>();
077    
078    /** The skins declared as external: name of the skin and file location */
079    protected Map<String, Path> _externalSkins = new HashMap<>();
080    
081    /** The skins declared in jar files */
082    protected Map<String, Path> _resourcesSkins = new HashMap<>();
083    
084    /** The avalon service manager */
085    protected ServiceManager _manager;
086    /** The excalibur source resolver */
087    protected SourceResolver _sourceResolver;
088    /** Avalon context */
089    protected Context _context;
090    /** Cocoon context */
091    protected org.apache.cocoon.environment.Context _cocoonContext;
092    /** The site manager */
093    protected SiteManager _siteManager;
094    /** The view parameters manager */
095    protected ViewParametersManager _viewParametersManager;
096    /** The view and parameters parser */
097    protected ViewAndParametersParser _viewAndParametersParser;
098    /** The extension point for type of view parameter */
099    protected ViewParameterTypeExtensionPoint _viewParametersEP;
100
101    @Override
102    public void service(ServiceManager manager) throws ServiceException
103    {
104        _manager = manager;
105        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
106    }
107    
108    @Override
109    public void contextualize(Context context) throws ContextException
110    {
111        _context = context;
112        _cocoonContext = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
113    }
114    
115    ServiceManager getServiceManager()
116    {
117        return _manager;
118    }
119    
120    Context getContext()
121    {
122        return _context;
123    }
124    
125    SourceResolver getSourceResolver()
126    {
127        return _sourceResolver;
128    }
129    
130    ViewParametersManager getViewParametersManager()
131    {
132        if (_viewParametersManager == null)
133        {
134            try 
135            {
136                _viewParametersManager = (ViewParametersManager) _manager.lookup(ViewParametersManager.ROLE);
137            } 
138            catch (ServiceException e) 
139            {
140                throw new RuntimeException(e);
141            }
142        }
143        return _viewParametersManager;
144    }
145    
146    ViewAndParametersParser getViewAndParametersParser()
147    {
148        if (_viewAndParametersParser == null)
149        {
150            try 
151            {
152                _viewAndParametersParser = (ViewAndParametersParser) _manager.lookup(ViewAndParametersParser.ROLE);
153            } 
154            catch (ServiceException e) 
155            {
156                throw new RuntimeException(e);
157            }
158        }
159        return _viewAndParametersParser;
160    }
161    
162    ViewParameterTypeExtensionPoint getViewParameterTypeExtensionPoint()
163    {
164        if (_viewParametersEP == null)
165        {
166            try 
167            {
168                _viewParametersEP = (ViewParameterTypeExtensionPoint) _manager.lookup(ViewParameterTypeExtensionPoint.ROLE);
169            } 
170            catch (ServiceException e) 
171            {
172                throw new RuntimeException(e);
173            }
174        }
175        return _viewParametersEP;
176    }
177    
178    public void initialize() throws Exception
179    {
180        // Skins can be in external locations
181        _listExternalSkins("context://" + RuntimeServlet.EXTERNAL_LOCATIONS);
182        
183        // Skins can be in jars
184        _listResourcesSkins();
185    }
186    
187    public void dispose()
188    {
189        for (Skin skin : _skins.values())
190        {
191            skin.dispose();
192        }
193        _skins.clear();
194    }
195    
196    private void _listResourcesSkins() throws IOException
197    {
198        Enumeration<URL> skinResources = getClass().getClassLoader().getResources("META-INF/ametys-skins");
199        
200        while (skinResources.hasMoreElements())
201        {
202            URL skinResource = skinResources.nextElement();
203            
204            try (InputStream is = skinResource.openStream();
205                 BufferedReader br = new BufferedReader(new InputStreamReader(is, "UTF-8")))
206            {
207                String skin;
208                while ((skin = br.readLine()) != null)
209                {
210                    int i = skin.indexOf(':');
211                    if (i != -1)
212                    {
213                        String skinName = skin.substring(0, i);       
214                        String skinResourceURI = skin.substring(i + 1);
215                        
216                        FileSystem skinFileSystem = JarFSManager.getInstance().getFileSystemByResource(skinResourceURI);
217                        Path skinPath = skinFileSystem.getPath(skinResourceURI);
218                        
219                        if (_isASkinPath(skinPath))
220                        {
221                            _resourcesSkins.put(skinName, skinPath);
222                        }
223                        else
224                        {
225                            getLogger().error("The skin '" + skinName + "' declared in a JAR file will be ignored as it is not a true skin");
226                        }
227                    }
228                }
229            }
230        }        
231    }
232
233    private void _listExternalSkins(String uri) throws Exception
234    {
235        Configuration externalConf;
236        
237        // Read file
238        Source externalLocation = null;
239        try
240        {
241            externalLocation = _sourceResolver.resolveURI(uri);
242            if (!externalLocation.exists())
243            {
244                throw new SourceNotFoundException("No file at " + uri);
245            }
246            
247            DefaultConfigurationBuilder externalConfBuilder = new DefaultConfigurationBuilder();
248            try (InputStream external = externalLocation.getInputStream())
249            {
250                externalConf = externalConfBuilder.build(external, uri);
251            }
252        }
253        catch (SourceNotFoundException e)
254        {
255            getLogger().debug("No external location file");
256            return;
257        }
258        finally
259        {
260            _sourceResolver.release(externalLocation);
261        }
262        
263        // Apply file
264        for (Configuration skinConf : externalConf.getChild("skins").getChildren("skin"))
265        {
266            String name = skinConf.getAttribute("name", null);
267            String location = skinConf.getValue(null);
268            
269            if (name != null && location != null)
270            {
271                Path skinDir = _getPath(location);
272                // Do not check at this time, since it is not read-only, this can change
273                _externalSkins.put(name, skinDir);
274            }
275        }
276    }
277    
278    /*
279     * Returns the corresponding file, either absolute or relative to the context path
280     */
281    private Path _getPath(String path)
282    {
283        if (path == null)
284        {
285            return null;
286        }
287        
288        Path directory = Path.of(path);
289        if (directory.isAbsolute())
290        {
291            return directory;
292        }
293        else
294        {
295            return Path.of(_cocoonContext.getRealPath("/" + path));
296        }
297    }
298
299    /**
300     * Get the list of existing skins
301     * @return A set of skin names. Can be null if there is an error.
302     */
303    public Set<String> getSkins()
304    {
305        try
306        {
307            Set<String> skins = new HashSet<>();
308            
309            // JAR skins
310            skins.addAll(_resourcesSkins.keySet());
311            
312            // External skins
313            _externalSkins.entrySet().stream()
314                          .filter(e -> _isASkinPath(e.getValue()))
315                          .map(Map.Entry::getKey)
316                          .forEach(skins::add);
317            
318            // Skins at location
319            Path skinsDir = getLocalSkinsLocation();
320            if (Files.exists(skinsDir) && Files.isDirectory(skinsDir))
321            {
322                try (Stream<Path> files = Files.list(skinsDir))
323                {
324                    files.filter(this::_isASkinPath)
325                        .map(p -> p.getFileName().toString())
326                        .forEach(skins::add);
327                }
328            }
329            
330            return skins;
331        }
332        catch (Exception e)
333        {
334            getLogger().error("Can not determine the list of skins available", e);
335            return null;
336        }
337    }
338    
339    /**
340     * Get a skin
341     * @param id The id of the skin
342     * @return The skin or null if the skin does not exist
343     */
344    public Skin getSkin(String id)
345    {
346        Path skinPath;
347        boolean modifiable = false;
348        
349        skinPath = _resourcesSkins.get(id);
350        if (skinPath == null)
351        {
352            skinPath = _externalSkins.get(id);
353            if (skinPath == null)
354            {
355                skinPath = getLocalSkinsLocation().resolve(id);
356                if (Files.exists(skinPath) && Files.isDirectory(skinPath))
357                {
358                    modifiable = true;
359                }
360                else
361                {
362                    // No skin with this name
363                    return null;
364                }
365            }
366        }
367        
368        if (!_isASkinPath(skinPath))
369        {
370            // A skin with this name but is not a skin
371            if (_skins.containsKey(skinPath))
372            {
373                Skin skin = _skins.get(skinPath);
374                skin.dispose();
375            }
376            
377            _skins.put(skinPath, null);
378            return null;
379        }
380        
381        Skin skin = _skins.get(skinPath);
382        if (skin == null)
383        {
384            skin = new Skin(id, skinPath, modifiable, this);
385            _skins.put(skinPath, skin);
386        }
387        
388        skin.refreshValues();
389        return skin;
390    }
391    
392    /**
393     * Get the skin and recursively its parents skins in the right weight order
394     * @param skin The non null skin to check
395     * @return The list of parent skins INCLUDING the given one
396     */
397    public List<Skin> getSkinAndParents(Skin skin)
398    {
399        List<Skin> skinAndParents = new ArrayList<>();
400        
401        skinAndParents.add(skin);
402        
403        for (String parentSkinId : skin.getParents())
404        {
405            Skin parentSkin = getSkin(parentSkinId);
406            if (parentSkin == null)
407            {
408                throw new IllegalStateException("The skin '" + skin.getId() + "' extends the unexisting skin '" + parentSkinId + "'");
409            }
410            skinAndParents.addAll(getSkinAndParents(parentSkin));
411        }
412        
413        return skinAndParents;
414    }
415    
416    /**
417     * Compute the skin that are directly derived from the given one
418     * @param skin A non null skin
419     * @return A non null list of skins
420     */
421    public List<Skin> getDirectChildren(Skin skin)
422    {
423        List<Skin> children = new ArrayList<>();
424        
425        for (String otherSkinId : getSkins())
426        {
427            if (!otherSkinId.equals(skin.getId()))
428            {
429                Skin otherSkin = getSkin(otherSkinId);
430                if (otherSkin.getParents().contains(skin.getId()))
431                {
432                    children.add(otherSkin);
433                }
434            }
435        }
436        
437        return children;
438    }
439    
440    /**
441     * Get the skins location
442     * @return the skin location
443     */
444    public Path getLocalSkinsLocation()
445    {
446        try
447        {
448            Request request = ContextHelper.getRequest(_context);
449            String skinLocation = (String) request.getAttribute("skin-location");
450            if (skinLocation != null)
451            {
452                return Path.of(RuntimeConfig.getInstance().getAmetysHome().getAbsolutePath(), skinLocation);
453            }
454        }
455        catch (CascadingRuntimeException e)
456        {
457            // Ignore
458        }
459        
460        return Path.of(_cocoonContext.getRealPath("/skins"));
461    }
462
463    /**
464     * Get the skin name from request or <code>null</code> if not found
465     * @return The skin name or <code>null</code>
466     */
467    public String getSkinNameFromRequest ()
468    {
469        Request request = null;
470        try
471        {
472            request = ContextHelper.getRequest(_context);
473            
474            return getSkinNameFromRequest(request);
475        }
476        catch (RuntimeException e)
477        {
478            // No running request
479            return null;
480        }
481    }
482    
483    /**
484     * Get the skin name from request or <code>null</code> if not found
485     * @param request The request
486     * @return The skin name or <code>null</code>
487     */
488    public String getSkinNameFromRequest(Request request)
489    {
490        if (_siteManager == null)
491        {
492            try
493            {
494                _siteManager = (SiteManager) _manager.lookup(SiteManager.ROLE);
495            }
496            catch (ServiceException e)
497            {
498                throw new IllegalStateException(e);
499            }
500        }
501        
502        if (request == null)
503        {
504            return null;
505        }
506        
507        // First, search the skin name in the request attributes.
508        String skinName = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SKIN_ID);
509        
510        // Then, test if the site name is present as a request attribute to deduce the skin name.
511        if (StringUtils.isEmpty(skinName))
512        {
513            String siteName = WebHelper.getSiteName(request);
514            
515            if (StringUtils.isNotEmpty(siteName))
516            {
517                skinName = _siteManager.getSite(siteName).getSkinId();
518            }
519        }
520        
521        return skinName;
522    }
523    
524    private boolean _isASkinPath(Path skinDir)
525    {
526        if (!Files.exists(skinDir) || !Files.isDirectory(skinDir))
527        {
528            return false;
529        }
530        
531        Path templateDir = skinDir.resolve(Skin.TEMPLATES_PATH);
532        Path confDir = skinDir.resolve(Skin.CONF_PATH);
533        if ((!Files.exists(templateDir) || !Files.isDirectory(templateDir))
534                && (!Files.exists(confDir) || Files.isDirectory(confDir)))
535        {
536            return false;
537        }
538        
539        return true;
540    }
541}