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