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