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
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    ModelItemTypeExtensionPoint getViewParameterTypeExtensionPoint()
163    {
164        if (_viewParametersEP == null)
165        {
166            try 
167            {
168                _viewParametersEP = (ModelItemTypeExtensionPoint) _manager.lookup(ModelItemTypeExtensionPoint.ROLE_VIEW_PARAM);
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        if (id == null)
347        {
348            return null;
349        }
350        
351        Path skinPath;
352        boolean modifiable = false;
353        
354        skinPath = _resourcesSkins.get(id);
355        if (skinPath == null)
356        {
357            skinPath = _externalSkins.get(id);
358            if (skinPath == null)
359            {
360                skinPath = getLocalSkinsLocation().resolve(id);
361                if (Files.exists(skinPath) && Files.isDirectory(skinPath))
362                {
363                    modifiable = true;
364                }
365                else
366                {
367                    // No skin with this name
368                    return null;
369                }
370            }
371        }
372        
373        if (!_isASkinPath(skinPath))
374        {
375            // A skin with this name but is not a skin
376            if (_skins.containsKey(skinPath))
377            {
378                Skin skin = _skins.get(skinPath);
379                skin.dispose();
380            }
381            
382            _skins.put(skinPath, null);
383            return null;
384        }
385        
386        Skin skin = _skins.get(skinPath);
387        if (skin == null)
388        {
389            skin = new Skin(id, skinPath, modifiable, this);
390            _skins.put(skinPath, skin);
391        }
392        
393        skin.refreshValues();
394        return skin;
395    }
396    
397    /**
398     * Get the skin and recursively its parents skins in the right weight order
399     * @param skin The non null skin to check
400     * @return The list of parent skins INCLUDING the given one
401     */
402    public List<Skin> getSkinAndParents(Skin skin)
403    {
404        List<Skin> skinAndParents = new ArrayList<>();
405        
406        skinAndParents.add(skin);
407        
408        for (String parentSkinId : skin.getParents())
409        {
410            Skin parentSkin = getSkin(parentSkinId);
411            if (parentSkin == null)
412            {
413                throw new IllegalStateException("The skin '" + skin.getId() + "' extends the unexisting skin '" + parentSkinId + "'");
414            }
415            skinAndParents.addAll(getSkinAndParents(parentSkin));
416        }
417        
418        return skinAndParents;
419    }
420    
421    /**
422     * Compute the skin that are directly derived from the given one
423     * @param skin A non null skin
424     * @return A non null list of skins
425     */
426    public List<Skin> getDirectChildren(Skin skin)
427    {
428        List<Skin> children = new ArrayList<>();
429        
430        for (String otherSkinId : getSkins())
431        {
432            if (!otherSkinId.equals(skin.getId()))
433            {
434                Skin otherSkin = getSkin(otherSkinId);
435                if (otherSkin.getParents().contains(skin.getId()))
436                {
437                    children.add(otherSkin);
438                }
439            }
440        }
441        
442        return children;
443    }
444    
445    /**
446     * Get the skins location
447     * @return the skin location
448     */
449    public Path getLocalSkinsLocation()
450    {
451        try
452        {
453            Request request = ContextHelper.getRequest(_context);
454            String skinLocation = (String) request.getAttribute("skin-location");
455            if (skinLocation != null)
456            {
457                return Path.of(RuntimeConfig.getInstance().getAmetysHome().getAbsolutePath(), skinLocation);
458            }
459        }
460        catch (CascadingRuntimeException e)
461        {
462            // Ignore
463        }
464        
465        return Path.of(_cocoonContext.getRealPath("/skins"));
466    }
467
468    /**
469     * Get the skin name from request or <code>null</code> if not found
470     * @return The skin name or <code>null</code>
471     */
472    public String getSkinNameFromRequest ()
473    {
474        Request request = null;
475        try
476        {
477            request = ContextHelper.getRequest(_context);
478            
479            return getSkinNameFromRequest(request);
480        }
481        catch (RuntimeException e)
482        {
483            // No running request
484            return null;
485        }
486    }
487    
488    /**
489     * Get the skin name from request or <code>null</code> if not found
490     * @param request The request
491     * @return The skin name or <code>null</code>
492     */
493    public String getSkinNameFromRequest(Request request)
494    {
495        if (_siteManager == null)
496        {
497            try
498            {
499                _siteManager = (SiteManager) _manager.lookup(SiteManager.ROLE);
500            }
501            catch (ServiceException e)
502            {
503                throw new IllegalStateException(e);
504            }
505        }
506        
507        if (request == null)
508        {
509            return null;
510        }
511        
512        // First, search the skin name in the request attributes.
513        String skinName = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SKIN_ID);
514        
515        // Then, test if the site name is present as a request attribute to deduce the skin name.
516        if (StringUtils.isEmpty(skinName))
517        {
518            String siteName = WebHelper.getSiteName(request);
519            
520            if (StringUtils.isNotEmpty(siteName))
521            {
522                skinName = _siteManager.getSite(siteName).getSkinId();
523            }
524        }
525        
526        return skinName;
527    }
528    
529    private boolean _isASkinPath(Path skinDir)
530    {
531        if (!Files.exists(skinDir) || !Files.isDirectory(skinDir))
532        {
533            return false;
534        }
535        
536        Path templateDir = skinDir.resolve(Skin.TEMPLATES_PATH);
537        Path confDir = skinDir.resolve(Skin.CONF_PATH);
538        if ((!Files.exists(templateDir) || !Files.isDirectory(templateDir))
539                && (!Files.exists(confDir) || Files.isDirectory(confDir)))
540        {
541            return false;
542        }
543        
544        return true;
545    }
546}